Operational reference for common tasks.
git checkout main && git pull
git checkout -b feat/descriptionnpm run lint
git add <files>
git commit -m "feat: description"Commit prefixes: feat:, fix:, chore:, docs:, refactor:, style:,
test:. Subject line under 80 characters, imperative mood.
git push -u origin feat/description
gh pr create --title "feat: description" --body "## Summary\n- ..."gh pr merge <number> --merge
git checkout main && git pull
git branch -d feat/description
git push origin --delete feat/descriptiongh issue list --state open
gh issue list --label epic --state openReference the issue in the PR body with Closes #<number>. GitHub closes it
automatically on merge.
- Add an entry to
src/data/lenses.tsfollowing theLensinterface - All boolean fields use
is/hasprefix (e.g.hasAutofocus,isWeatherSealed) - Price in USD, rounded to nearest $250
- Only fill optical quality fields if data comes from a trusted source (see 2.4)
- Leave optical fields empty for lenses without trusted data — they will appear in the Lens Explorer but be excluded from the Genre Guide
- Run
npm run lintto verify
- Add an entry to
src/data/cameras.tsfollowing theCamerainterface - Same boolean naming and price conventions as lenses
- Run
npm run lintto verify
- Choose the correct sub-interface in
src/types/accessory.tsbycategory - Add an entry to
src/data/accessories.ts(when created) - If no existing category fits, add a
GenericAccessoryentry
Only score lenses that have optical data from trusted review sources. Do not guess or estimate optical quality — showing nothing is better than showing wrong data.
- Lens Explorer — shows all lenses, sortable by specs. No scoring needed.
- Genre Guide — shows only lenses with optical data. Lenses with empty optical fields are excluded entirely.
- Scoring functions return
nullwhen optical fields are missing.
See src/data/reviews.ts for the full directory with methodology
(lab/field) and trust (1-3) ratings. Trust-3 sources:
| Trust 3 | Methodology |
|---|---|
| LensRentals | Lab — optical bench MTF |
| LensTip | Lab — Imatest MTF charts |
| OpticalLimits | Lab — Imatest MTF |
| DxOMark | Lab — perceptual megapixel |
| The Digital Picture | Lab — ISO 12233 chart |
| ePHOTOzine | Lab — Imatest MTF |
| ColorFoto | Lab — proprietary bench |
| Dustin Abbott | Field — systematic + lab hybrid |
| DPReview | Field — comprehensive |
| Phillip Reeve | Field — manual focus specialist |
| Lloyd Chambers | Field — systematic high-res |
| Lonely Speck | Field — astrophotography |
Do not use: Ken Rockwell — not a trusted data source.
Prerequisite: the lens must have reviews from at least one trust 3 source. See ADR-014 for the full rubric and reference scorings.
Step 1 — Identify sources
Search ALL trust-3 sources for the lens before proceeding:
- Lab (trust 3): LensRentals, LensTip, OpticalLimits, DxOMark, The Digital Picture, ePHOTOzine, ColorFoto
- Field (trust 3): Dustin Abbott, DPReview, Phillip Reeve, Lloyd Chambers, Lonely Speck
Do not skip sources — missing one can leave scoreable fields empty (e.g. Dustin Abbott provided astigmatism + bokeh data for the Viltrox 9mm that two other sources missed).
Check if the optical formula changed between versions (e.g. XF 27mm f/2.8 R WR uses the same optics as the original).
Step 2 — Collect data per field
Run targeted per-field searches — one search per optical field, not one broad query. For slow sites (LensTip), search snippets return cached data even when direct fetch times out.
Fields to collect (all 0-2 scale, 0.5 steps):
| Field | Source type | How to score |
|---|---|---|
| centerStopped | lpmm at sweet spot | % of sensor max (see ADR-014) |
| cornerStopped | lpmm at sweet spot | % of sensor max |
| centerWideOpen | lpmm at max aperture | % of sensor max |
| cornerWideOpen | lpmm at max aperture | % of sensor max (often missing) |
| astigmatism | % S/T difference | < 5% = 2.0, 5-10% = 1.5, etc. |
| coma | Point-source test only | Qualitative word mapping |
| sphericalAberration | Focus shift test | Qualitative word mapping |
| longitudinalCA | Colour fringing test | Qualitative word mapping |
| lateralCA | % at 70% from center | LensTip scale (< 0.04% = 2.0) |
| distortion | % RAW uncorrected | < 0.3% = 2.0, 0.3-1.0% = 1.5, etc. |
| vignettingWideOpen | EV at max aperture, RAW | < 0.5 = 2.0, 0.5-1.0 = 1.5, etc. |
| vignettingStopped | EV at f/5.6-f/8, RAW | Same scale |
| bokeh | Qualitative assessment | Word mapping |
| flareResistance | Qualitative assessment | Word mapping |
Red flags: fast lenses (f/2 or wider) should have longitudinalCA, sphericalAberration, and coma data. Wide-angle lenses should have lateralCA and distortion data. If a field that should be significant is missing, search specifically before accepting undefined.
Step 3 — Apply rubric
For each field, apply the threshold from ADR-014. Use only discrete values: 0, 0.5, 1.0, 1.5, 2.0. When sources disagree, use the highest-trust source. When ambiguous, round conservative (lower).
If a field has no data from any source, leave it undefined. Do not infer from related fields or optical construction alone.
Step 4 — Fallback sources (only when no lab/field data exists)
- Official MTF chart → astigmatism only (S/M divergence)
- Optical construction + zero complaints → sphericalAberration, CA (not coma, bokeh, or flare)
Step 5 — Write the data
- Add optical fields +
sweetSpotAperture+reviewSourcesto the lens entry insrc/data/lenses.ts - Add reference scoring table to
docs/optical-specs/<slug>/scoring-log.md - If astigmatism was scored from an official MTF chart, save the chart
image and a companion
.mdanalysis indocs/optical-specs/<slug>/ - Run
npm run validateto verify
Step 6 — Verify
Check that the scored lens appears in the Genre Guide for relevant genres (once genre formulas are implemented).
Fetch a web page (four-tier auto-escalation):
Run from the tools/ directory so python -m pagefetch resolves the package.
For bare CLI fetches, pass --cache-dir ../.cache/fetch so the fetch shares the
one project cache the brand tools use — otherwise the CLI's default creates a
separate tools/.cache/pagefetch. (Brand tools set the cache dir automatically;
only the bare CLI needs the flag. PAGEFETCH_CACHE_DIR also works if you prefer
an env var, but the flag is simpler for one-off fetches.)
py -m pagefetch <url> --cache-dir ../.cache/fetch # share the project cache
py -m pagefetch <url> # auto mode: urllib → Playwright → Nodriver → UC
py -m pagefetch <url> --html # raw HTML output
py -m pagefetch <url> --js # force Playwright (JS-rendered pages)
py -m pagefetch <url> --nodriver # force Nodriver (headed Chrome, bot bypass)
py -m pagefetch <url> --uc # force SeleniumBase UC (headless fallback)
py -m pagefetch <url> --no-cache # bypass cache, fetch fresh
py -m pagefetch --batch urls.txt --nodriver --output-dir out/ # batch mode
py -m pagefetch --clean-cache --cache-dir ../.cache/fetch # purge bot/404 junk
py -m pagefetch --clean-cache --dry-run --cache-dir ../.cache/fetch # preview onlyResponses are cached in .cache/fetch/ (me-fuji points the cache there via
PAGEFETCH_CACHE_DIR; the cache_dir precedence is --cache-dir / explicit
arg > env var > CWD-relative default, validated at construction). Bot-blocked
and 404/gone responses are never cached
and self-heal on read; --clean-cache sweeps any junk that accumulated
(ADR-037 — content-based validity, no TTL). See tools/pagefetch/README.md
for the architecture and
library API.
Run the Python tool tests:
cd tools && py -m pytest pagefetch/tests/ brandkit/tests/ mtfdigitizer/tests/ -vRun the MTF digitizer calibration runner:
cd tools && py -m mtfdigitizer.calibrate
cd tools && py -m mtfdigitizer.calibrate --write-readingsRuns extract_chart() against every reference chart with both a hand-measured
plot box and eye-read ground truth (tools/mtfdigitizer/referenceset/charts.py),
reports per-field absolute offset distribution + an aggregate. Output: stdout
only by default. With --write-readings, additionally writes one markdown
grid per chart to tools/mtfdigitizer/referenceset/readings/<slug>.md for
diffing across algorithm changes. Findings live in
tools/mtfdigitizer/referenceset/calibration.md — update the markdown after a
run that changes the numbers materially. Reference the calibration entries
when discussing the ADR-038 offset tolerance band or the 0.75 render-match
threshold.
Shape-error spike intake (ADR-071 prevention). Any spike that describes
a shape error on a reference-set lens (curves tracking each other,
dip-and-recover, sister-fill misfires) MUST run
py -m mtfdigitizer.calibrate --write-readings and verify the diagnosis
against the per-frac GT | EX | Δ grid in
tools/mtfdigitizer/referenceset/readings/<slug>.md BEFORE the spike is
treated as actionable. Visual inspection of the overlay PNG is a useful
diagnostic but not authoritative — two legitimately near-coincident GT
curves can look like a tracking error to the eye. The readings dump is the
authoritative signal and would have refuted spike #1282 at intake. See
ADR-071 for the worked example and post-mortem.
Run the MTF digitizer production extractor (Tier 2 per ADR-041):
cd tools && py -m mtfdigitizer.extract <lens-slug> # one lens, gated commit
cd tools && py -m mtfdigitizer.extract <lens-slug> --accept # bypass HOLD, write log
cd tools && py -m mtfdigitizer.extract --all # every pending Tier 2 lens; stops on first HOLD
cd tools && py -m mtfdigitizer.extract --check # re-render all production logs, fail on staleness
cd tools && py -m mtfdigitizer.extract <anchor-slug> --anchor-artifacts # Tier 1 anchor: regen overlay/svg/review HTML without touching the calibration log (#1262)Sister to calibrate for production-tier lenses (Tier 2 = plot_box set but no
ground_truth). Always writes overlay PNG + SVG + 3-panel review HTML under
docs/optical-specs/<lens-slug>/; writes the production
digitization-log.md only when the confidence gate accepts (render-match
precision ≥ 0.80 AND IoU ≥ 0.20 AND all plausibility priors hold) OR
--accept is passed. OVERLAY_GLANCE_REQUIRED=True is the initial gate
posture — HIGH verdict alone does not auto-commit; maintainer glances at the
overlay PNG and re-runs with --accept. See ADR-041 for the two-tier
rationale and tools/mtfdigitizer/extract.py for the gate knob.
--check is enforced in CI by the staleness job (ADR-082): a PR
touching tools/** or docs/optical-specs/** fails if any committed
digitization-log.md drifts from a fresh render. Run it locally before
committing any change that affects extractor output.
Multi-view lenses (zooms). A lens with additional_views set on its
ReferenceChart (today: the 5 Sigma zooms with wide + tele charts) emits
one set of inspection artifacts per view, file-named by chart stem so
nothing collides in the folder, plus a single digitization-log.md with
one ## Panel section per view. The gate aggregates across views: any
LOW verdict holds the whole lens (partial commits aren't a meaningful
state when one log covers every view). Per ADR-033 the wide-end
diffraction chart is the canonical-for-scoring view; tele rides along
as supplementary data on the same log. (#1037)
Write a per-stage diagnostic bundle for one chart (ADR-050):
Two entry points produce the same bundle:
# Production-faithful path — routes through extract's own view/aperture
# orchestration, so what you debug is byte-identical to what extract ships.
cd tools && py -m mtfdigitizer.extract <lens-slug> --debug # one Tier 2 lens
cd tools && py -m mtfdigitizer.extract <slug> --anchor-artifacts --debug # one Tier 1 anchor
# Reference-set path — also takes cohort / corpus batches.
cd tools && py -m mtfdigitizer.diagnose <lens-slug> # one chart
cd tools && py -m mtfdigitizer.diagnose --brand <prefix> # whole brand cohort (e.g. `ttartisan`)
cd tools && py -m mtfdigitizer.diagnose --all # whole corpus (slow)Both run extract_chart with a DiagnosticSink that writes one named
PNG per pipeline stage (source, plotbox, hue masks, skeletons, presence
masks, sampling overlay, fallback diff, symmetry diff, emit) plus a
manifest.json into docs/optical-specs/<slug>/diagnostic/. Multi-pass
charts get one subdirectory per pass. The bundle is gitignored —
regenerate on demand. Prefer extract --debug for a lens you are
digitizing: it uses the exact profile extract commits, including
ADR-063 per-view aperture overrides that diagnose cannot see (it omits
the view). Use diagnose for cohort sweeps or Tier 1 reference charts.
Extraction values are byte-identical with or without the sink — py -m mtfdigitizer.svg --check MUST pass after any run.
Triage a wrong-looking chart — scan the stages in order, find the first divergence:
-
Regenerate the bundle for the lens:
py -m mtfdigitizer.extract <slug> --debug. -
Open
diagnostic/and scan the numbered PNGs in pipeline order (01 → 09). The first stage whose overlay diverges from the printed curves is the culprit; every later stage inherits its error, so stop at the first divergence rather than chasing the wrong output at stage 09. -
Jump straight to the likely stage using the failure-description map (from ADR-050):
Symptom First stage to inspect Then "missing segments" 03 (hue mask) 04 (skeleton) "30lpmm completely wrong" 03 (hue mask) 04 (skeleton, freq-split dispatch) "edge too low" 04 (skeleton) 06 (sampling at fraction 1.0) "corner crossing swapped" 04 (skeleton) 07 (sister fallback flipped labels) "max wrong stopped ok" 03 (per-aperture mask) 04 (skeleton with stopped curves masked out) "overlay not refreshed" 09 (emit) provenance only — committed SVG vs bundle -
Fix that stage, re-run
--debug, and confirm the divergence is gone at that stage's PNG. The bundle identifies which stage broke; it does not fix it (ADR-050 "What this does not do").
Refresh per-lens calibration-tier digitization logs (Tier 1 per ADR-041):
cd tools && py -m mtfdigitizer.log # Tokina lenses (default)
cd tools && py -m mtfdigitizer.log --all # every lens with a runnable chart
cd tools && py -m mtfdigitizer.log --check # verify committed Tokina logs are up to date
cd tools && py -m mtfdigitizer.log --all --check # verify ALL anchor logs are up to date (use when anchor GT or ChartView semantics change)Writes docs/optical-specs/<lens-slug>/digitization-log.md for Tier 1
calibration anchors (lenses with eye-read ground truth). Tier 2 production
lenses use the parallel writer baked into py -m mtfdigitizer.extract above —
their logs omit the EYE column per ADR-041. Each log is a generated file —
never hand-edit. Run --check before committing any change that affects
pipeline output (algorithm tweaks, ground-truth corrections, plot-box edits)
and refresh the logs if it reports stale. See ADR-040 for the file format.
Rename optical-specs MTF files to named suffixes (ADR-033):
cd tools && py -m mtfdigitizer.rename <slug> --dry-run # preview one folder
cd tools && py -m mtfdigitizer.rename <slug> --apply # execute one folder
cd tools && py -m mtfdigitizer.rename --dry-run # preview every folderReads each folder's analysis.md MTF charts list, maps numeric-suffix
files (-mtf-1.png, -mtf-2.png) to canonical named suffixes
(-mtf-diffraction.png, -mtf-geometric.png), and rewrites file
moves, analysis.md link tables, and referenceset/charts.py
chart_path literals atomically. Fails loud when a numeric file on
disk is unlabeled in analysis.md, when a label is unrecognised, or
when two files would collide on the same suffix (the zoom case
deferred to a follow-up PR). After applying, regenerate logs with
py -m mtfdigitizer.log --all (Tier 1) and
py -m mtfdigitizer.extract --accept <slug> (Tier 2) so the chart
path in each digitization-log.md matches the new file name. See
#1017 and ADR-033 §"MTF chart naming".
Fujifilm-specific tooling (ADR-043, per-frequency chart family):
cd tools && py -m mtfdigitizer.fuji_plotbox <chart.png> # auto-detect plot box for one Fuji chart
cd tools && py -m mtfdigitizer.scripts.scaffold_fuji_tier2 # preview Tier 2 ReferenceChart entries
cd tools && py -m mtfdigitizer.scripts.scaffold_fuji_tier2 --write # materialize _fuji_tier2_charts.py
cd tools && py -m mtfdigitizer.scripts.emit_fuji_tier2 # preview TS object literals
cd tools && py -m mtfdigitizer.scripts.emit_fuji_tier2 --write # patch src/data/mtf-readings.ts
cd tools && py -m mtfdigitizer.scripts.emit_fuji_tier2 --check # diff in-memory re-emit vs committed file; exit 1 on drift (#1296)Fujifilm publishes one chart image per spatial frequency
(-15lp.png, -20lp.png, ...), so the standard single-image
extractor needs orchestration to merge per-frequency views. The
above tools handle that case end-to-end:
fuji_plotbox— composes RGBA over white, detects gridlines + tick-label clusters, calibratesimage_height_mmfrom the mount default (GF=26.9 mm, XF=14.2 mm — the sensor half-diagonal Fuji publishes MTF out to). 198/199 Fuji charts in the current corpus detect cleanly.scaffold_fuji_tier2— walks everydocs/optical-specs/fujifilm-*folder, runs the detector, groups charts by lens, and emits a_fuji_tier2_charts.pymodule of Tier 2ReferenceChartentries (60 lenses, 193 chart views as of #1058). Imported bycharts.pyvia concat intoREFERENCE_CHARTS.emit_fuji_tier2— translates the production digitization-log artifacts into TSMtfDataliterals and patches them intosrc/data/mtf-readings.ts. Handles prime (1 panel, all frequencies merged) and zoom (wide + tele × all frequencies) cases. Walks both Tier 1 anchors and Tier 2 bulk (everyfujifilm-permfreqchart). Derives aperture and focal length from the slug; source URL is read fromlenses.ts:officialUrl(fail-loudKeyErrorif missing) — see #1062. Eye-read overrides preserved across--writeand--checkvia_emit_overrides(#1301, #1305): any cell with a comment block containing the literaleye-read overridemarker immediately above it stays verbatim, keyed by(aperture, position_mm, freq).
Workflow when adding a new Fuji lens (or another brand using the
per-frequency convention): (1) drop the chart PNGs under the lens
folder; (2) re-run scaffold_fuji_tier2 --write to refresh the Tier 2
entries; (3) run extract <slug> --accept to commit the production
artifacts; (4) run emit_fuji_tier2 --write to update
mtf-readings.ts. The shared per-frequency orchestrator lives in
tools/mtfdigitizer/per_frequency.py.
TTartisan-specific tooling (ADR-044, multi-aperture chart family):
cd tools && py -m mtfdigitizer.scripts.scaffold_ttartisan_tier2 # preview Tier 2 ReferenceChart entries
cd tools && py -m mtfdigitizer.scripts.scaffold_ttartisan_tier2 --write # materialize _ttartisan_tier2_charts.py
cd tools && py -m mtfdigitizer.scripts.emit_ttartisan_tier2 # preview TS object literals
cd tools && py -m mtfdigitizer.scripts.emit_ttartisan_tier2 --write # patch src/data/mtf-readings.ts
cd tools && py -m mtfdigitizer.scripts.emit_ttartisan_tier2 --check # diff in-memory re-emit vs committed file; exit 1 on drift (#1296)TTartisan publishes one chart image per lens packing TWO apertures by
color encoding (black + grey at max aperture, red + orange at the
stopped aperture). The orchestrator (extract.py:_run_view_passes,
ADR-044) fans out one extractor pass per aperture, each with the
profile's hues filtered to one aperture's bucket. Inspection
artifacts (SVG / overlay PNG / review HTML) get an aperture suffix
so the two passes don't overwrite each other's files.
ttartisan_plotbox— classifies each chart by counting two-digit x-axis tick labels (APS-C: 0/3/7/10/13 → image_height 14 mm; GFX or full-frame: 0/5/10/15/20 → image_height 20.5 mm) and returns a hand-verified template-constant plot box per scheme. Pixel auto- detection drifts ±2 px across the 19-chart cohort; the constants ship as the single source of truth.scaffold_ttartisan_tier2— walksdocs/optical-specs/ ttartisan-*, runs the classifier, and writes a_ttartisan_tier2_charts.pymodule of 19ReferenceChartentries. Per-lens (max, stopped) aperture pair eye-read from each chart's legend and shipped in the script's_APERTURES_BY_SLUGtable — pixel-OCR of the legend text was too unreliable (8/19 correct on the 800x600 template).emit_ttartisan_tier2— emits TWOMtfChartpanels per literal (one per aperture pass), with the actual f-numbers fromchart.apertures[i]aligned positionally with the profile'sapertures_per_chartlabels.sourceURL fromlenses.ts: officialUrl(fail-loudKeyErrorif missing — see #1062). Eye-read overrides preserved across--writeand--checkvia_emit_overrides(#1301): any cell with a comment block containing the literaleye-read overridemarker immediately above it stays verbatim, keyed by(aperture, position_mm, freq).
Workflow when adding a new TTartisan lens (or another brand using
the multi-aperture-by-color convention): (1) drop the chart PNG under
the lens folder; (2) add the lens's (max, stopped) aperture pair to
_APERTURES_BY_SLUG (eye-read from the chart legend); (3) re-run
scaffold_ttartisan_tier2 --write to refresh the entries; (4) run
extract <slug> --accept per lens once the two-aperture overlays
look correct; (5) run emit_ttartisan_tier2 --write to patch
mtf-readings.ts with the cohort.
Samyang-specific tooling (ADR-063, two-panel stacked-aperture family):
cd tools && py -m mtfdigitizer.scripts.scaffold_samyang_tier2 # preview Tier 2 ReferenceChart entries
cd tools && py -m mtfdigitizer.scripts.scaffold_samyang_tier2 --write # materialize _samyang_tier2_charts.pySamyang publishes one chart image per lens with TWO stacked panels:
MAX aperture on top, F8 below. Both panels share the same hue palette;
only the plot box differs. The orchestrator uses the per-view aperture
override on ChartView (ADR-063): the primary view emits at the
chart's first aperture label ("MAX"), an additional_view with its
own plot_box and aperture="F8" emits the F8 panel. Inspection
artifacts get a -F8 suffix for the second pass so the two views'
overlay/SVG/HTML don't overwrite each other.
samyang_plotbox— auto-detects both panel plot boxes per chart via permissive-threshold axis-line probes. Handles all three canvas widths Samyang publishes (462/490/498 px) and the AF-series charts (different x_right, slightly shifted F8 y-range) uniformly. No per-slug plot-box overrides needed.scaffold_samyang_tier2— walksdocs/optical-specs/samyang-*, runs the detector per chart, and writes a_samyang_tier2_charts.pymodule of 18ReferenceChartentries withadditional_views= (ChartView(plot_box=<F8>, aperture="F8"),). Per-lensimage_height_mmships in the script's_IMAGE_HEIGHT_MM_BY_SLUGtable (eye-read from the chart's x-axis tick labels — typically 21.6 for full-frame, 14.2 for APS-C; Samyang 8mm and 12mm fisheyes are APS-C despite no-csslug suffix).
Workflow when adding a new Samyang lens (or another brand using the
multi-panel stacked-aperture convention): (1) drop the chart PNG under
the lens folder; (2) add the lens's image_height_mm to
_IMAGE_HEIGHT_MM_BY_SLUG; (3) re-run scaffold_samyang_tier2 --write to refresh the entries; (4) run extract <slug> --accept
per lens once the two-panel overlays look correct.
Tier 1 anchor helper generation (cross-brand):
cd tools && py -m mtfdigitizer.scripts.scaffold_anchor_helpers <slug> # preview
cd tools && py -m mtfdigitizer.scripts.scaffold_anchor_helpers <slug> --write # materialize
cd tools && py -m mtfdigitizer.scripts.scaffold_anchor_helpers <slug> --check # exit 1 if staleFor a Tier 1 anchor <slug> (i.e. a ReferenceChart whose
ground_truth is set in charts.py), generates two maintainer
artifacts in the lens's docs/optical-specs/<slug>/ folder:
per-view readhelper PNGs (3x upscale with green sample-position
lines + mm labels at top, plus orange dashed gridlines filling in
the OTF steps the source chart does not print natively — the grid
density and eye-precision are family-specific: 0.05 grid / ±0.02
for Fuji and TTartisan, 0.01 grid / ±0.005 with grey major anchors
at every 0.1 for Zeiss Touit), and eye-read.md (legend + tables
pre-populated with the extractor's predictions — ADR-048's
cell-level marking convention: bare = silent verification,
! = corrected, ? = unknown / will become None). On re-run the
scaffolder PRESERVES !/? marks and refreshes unmarked cells.
Caveat: a bare (unmarked) value entered where the extractor
predicts nothing (—) counts as an unmarked cell and is clobbered
back to — on the next --write — always mark corrections at
extractor-empty cells with ! (S207 incident; warning tracked in
#1379).
Transcribing a marked-up eye-read to ground truth (ADR-048):
cd tools && py -m mtfdigitizer.eyeread <slug> # parse + report counts
cd tools && py -m mtfdigitizer.eyeread <slug> --apply # rewrite _<LENS>_GT in charts.py
cd tools && py -m mtfdigitizer.calibrate # score extractor vs new GTAfter --apply, re-run the scaffolder --write on the slug to
canonicalize the maintainer's table formatting (marks preserved),
verify --check is clean, and record the calibration run in
tools/mtfdigitizer/referenceset/calibration.md (dated run section
with per-chart block, per-panel in-band %, and aggregate) plus the
anchor's verified-shapes summary in REFERENCE_SET.md.
Style-family dispatch:
fujifilm-permfreq— one readhelper per spatial frequency (-15lp-readhelper.png, etc.). Helper base = the per-freq source PNG. Orange dashed gridlines added at every 0.05 OTF except the chart's own printed 0.2-step lines (Fuji prints 0.0/0.2/0.4/0.6/ 0.8/1.0 natively). GT-snippet skeleton uses_FUJI_<COHORT>_<FL>_GT(e.g._FUJI_GF_23_GT).ttartisan-4color-dual-aperture— one readhelper per aperture (<stem>-max-readhelper.png,<stem>-stopped-readhelper.png). Helper base = the clean source chart PNG (never the extractor overlay), so the maintainer's eye-read is not biased by the extractor's traced curves. Use the<stem>-<aperture>-overlay.png- the review HTML separately when comparing extractor output to
the chart. Orange dashed gridlines added at 0.05, 0.15, ..., 0.95
(TTartisan prints 0.1-step natively). GT-snippet skeleton uses
_TTARTISAN_<FL>_GTwith"max"/"stopped"aperture buckets (NOT f-numbers — the orchestrator keysresults_by_apertureon the profile'sapertures_per_charttuple; mismatched keys fail-loud in calibrate.py). Forttartisan-af-NNmm-...andttartisan-tilt-NNmm-...slugs the GT-var prepends the variant:_TTARTISAN_AF_NN_GT/_TTARTISAN_TILT_NN_GT(kept in sync betweeneyeread.gt_var_for_chartandscaffold_anchor_helpers._gt_var_for_chart).
- the review HTML separately when comparing extractor output to
the chart. Orange dashed gridlines added at 0.05, 0.15, ..., 0.95
(TTartisan prints 0.1-step natively). GT-snippet skeleton uses
multifreq-press-kit— one readhelper per panel (<stem>-max-readhelper.png,<stem>-stopped-readhelper.png), same naming as TTartisan dual-aperture but the apertures split positionally (two stacked plot panels with differentplot_box, not by hue). Helper base = the clean source chart PNG; each readhelper draws the 11 green sample lines over its panel only. Orange dashed gridlines added at every 0.05 OTF except the chart's printed 0.2-step lines (Zeiss prints every 0.20 natively). Six columns per panel (3 freqs × {S,M}). GT-snippet skeleton uses_ZEISS_TOUIT_<FL>_GTwith"max"/"stopped"aperture buckets (ADR-063 per-view aperture role labels, NOT f-numbers). No hue-filtered profile transformation — the extractor runs on each panel'splot_boxwith the unchanged profile (aperture_label=Noneon the HelperView).
Workflow for promoting a lens to Tier 1: (1) add a ReferenceChart
entry to REFERENCE_CHARTS in charts.py with _<LENS>_GT of
None placeholders + the preamble comment template; (2) add the slug
to the brand scaffolder's _TIER1_SKIP_SLUGS so re-runs don't
re-introduce the duplicate; (3) re-run the brand scaffolder
(--write) so _<brand>_tier2_charts.py drops the entry; (4) run
scaffold_anchor_helpers <slug> --write to emit the readhelper
PNGs + eye-read.md; (5) maintainer reviews eye-read.md,
overwrites wrong cells with !, marks unreadable cells with ?;
(6) py -m mtfdigitizer.eyeread <slug> --apply transcribes the
file into _<LENS>_GT in charts.py; (7)
py -m mtfdigitizer.calibrate reports per-field median |Δ| against
the new anchor. To add a new style family, extend
_resolve_helper_views and _extras_for in
scaffold_anchor_helpers.py.
When an eye-read cell changes after Tier 1 promotion (#1201):
GT in charts.py is the truth anchor, but the user-facing artifacts
are rendered from the extractor's output, not the GT. If the
extractor still produces the old value, three downstream artifacts
must be hand-patched to match GT:
src/data/mtf-readings.ts— the website's source data; emitted byemit_*_tier2 --write; future emit runs will overwrite the patch unless the extractor is fixed (track the risk via a follow-up issue, see #1202).docs/optical-specs/<slug>/<stem>-<aperture>.svg— provenance SVG rendered byrender_svg(extracted)inextract.py. Hand-patch the polyline points + dot circles to the corrected y coordinates.docs/optical-specs/<slug>/<stem>-<aperture>-overlay.png— the overlay on the original chart, rendered byrender_overlay( extracted.readings, ...)inreview.py. Re-render with a one-shot probe script that callsrender_overlay()directly with the readings dict patched at the corrected fractions, then delete the probe.
digitization-log.md is intentionally NOT patched — it is the
auto-generated diagnostic record of the extractor's actual output and
remains the signal for the future extractor fix. Document the
hand-patches in eye-read.md under a "Manual artifact patches"
section so a future maintainer / re-emit knows the discrepancy is
intentional.
Before authoring an analysis.md MTF charts list for a folder that
does not yet have one: check the source product page for parallel
chart sets. Multi-mount lenses (DG DN releases with a later Fujifilm X
mount addition, lenses with optional teleconverters) often publish
the same wide/tele chart pair multiple times — once per mount / TC
configuration. Match each numeric-suffix file on disk against the
source page's chart-section DOM order before transcribing labels.
Wuseria is X-mount only, so keep only the X-mount chart set; delete
the L/Sony FF and TC-only variants. Trusting an unverified prose
summary caused #1032 (the Sigma 100-400mm incident).
Run the MTF digitizer render-match scorer:
cd tools && py -m mtfdigitizer.scorerSister to calibrate: runs extract_chart() then score_chart() against the
same runnable subset, reports per-field render-match IoU + a polyline-on-skeleton
precision side metric + an aggregate. The IoU half of the threshold calibration
described in tools/mtfdigitizer/referenceset/REFERENCE_SET.md §"What
'calibration against the set' actually means". Output: stdout only. Findings
live in tools/mtfdigitizer/referenceset/scoring.md — update after a run that
materially changes the numbers. Reference scoring entries when discussing the
0.75 IoU threshold revision (the first run found the threshold fails 3/3
charts due to sparse-polyline vs dense-skeleton size asymmetry).
Run the MTF digitizer plausibility-priors runner:
cd tools && py -m mtfdigitizer.plausibilityRuns extract_chart() then check_all() against the same runnable subset,
reports which of the four physical-plausibility priors (center_ge_edge,
ten_ge_thirty, not_suspiciously_flat, in_range) fire per chart. The
second of the two confidence signals ADR-038 §"Confidence signal" requires —
catches render-match's flat-axis blind spot (the 300mm reflex case).
Output: stdout only. Findings live in
tools/mtfdigitizer/referenceset/plausibility.md — update after a run that
materially changes the numbers.
Run the MTF digitizer auto-triage gate:
cd tools && py -m mtfdigitizer.autotriageCombines both confidence signals into one HIGH/LOW verdict per
extraction pass (ADR-052 — one per (chart, aperture) for ADR-044
multi-aperture; one per chart for single-aperture):
precision ≥ 0.80 AND IoU ≥ 0.20 AND priors_pass ⇒ HIGH, else LOW with
reason codes. Reason codes route attention to extractor-side work
(precision_below_threshold, iou_below_threshold,
render_match_undefined) vs chart-side work (prior_failed_*). Stdout
prints the per-pass verdict and aggregate; also writes one 3-panel
review file per LOW pass under docs/optical-specs/<slug>/ (HIGH passes
skip, per ADR-038 §"Workflow"). Multi-aperture review files carry the
aperture suffix (<slug>-{max,stopped}-review.html) per ADR-044 + ADR-052.
The runner covers every chart with a plot_box (currently 101 of 103,
producing ~120 per-pass verdicts) — the ground-truth filter that
restricted earlier runs to the 14 calibration charts was lifted in
ADR-052. Findings live in tools/mtfdigitizer/referenceset/triage.md —
update after a run that materially changes the numbers. LOW passes are
the authoritative input for RC investigation: consume the *-review.html
files plus the reason codes, not eye-read diagnostics.
Render an MTF chart's provenance SVG from its readings:
cd tools && py -m mtfdigitizer.svg # writes SVGs for the 3 runnable charts
cd tools && py -m mtfdigitizer.svg --check # dry-run for CI/testsPure-Python SVG writer over ExtractedChart readings. Provenance role
only: lens-page rendering stays with src/components/static/MtfChart.astro,
which renders from the same MtfData shape at build time. Output:
docs/optical-specs/<slug>/<chart-stem>.svg. ViewBox 320×218 — the data
area is at the same coordinates as MtfChart.astro (320×200), the extra
18px is a legend strip the standalone SVG carries in-document. Honors
the B2 None contract: a None reading breaks the polyline at that vertex.
Render an MTF chart's 3-panel review file:
cd tools && py -m mtfdigitizer.review # writes review files for every runnable reference chart
cd tools && py -m mtfdigitizer.review --check # dry-run for CI/testsEmits one static HTML composite per chart (left = original PNG, right =
SVG from #971, bottom = overlay PNG with extractor polylines registered
to the same PlotBox). The standalone runner emits for every runnable
chart (useful for on-demand maintainer inspection of a HIGH chart); the
autotriage runner emits only for LOW charts (the production workflow,
per ADR-038 §"Workflow"). Output: <chart-stem>-review.html +
<chart-stem>-overlay.png under docs/optical-specs/<slug>/. HTML is
JS-free per the ADR — a viewer, not an editor. Replaces the deprecated
tools/mtf-overlay.html whose hand-tuned calibration is superseded by
deterministic plot-box registration.
ADR-044 multi-aperture charts (TTartisan cohort) fan out to one
(review HTML, overlay PNG) pair per aperture pass via
aperture_passes_for_view, with stems <chart-stem>-<aperture> (e.g.
ttartisan-50mm-f1-2-mtf-max-review.html +
ttartisan-50mm-f1-2-mtf-stopped-review.html). Matches svg.py's
<chart-stem>-<aperture>.svg and the per-pass review files
autotriage.py writes. Single-aperture charts keep their existing
1-pair output with no -<aperture> suffix.
Refresh stale review artifacts (regenerate-and-commit hygiene):
When the extractor changes shift overlay geometry, the committed
<chart-stem>-overlay.png artifacts drift from their source. The
refresh workflow:
- Branch:
chore/refresh-stale-overlays(or scope-specific name). - Run
py -m mtfdigitizer.reviewfromtools/. - Filter platform noise from real diffs: the regenerator writes
CRLF line endings on Windows; HTMLs with no template changes still
show as
Mingit status. Usegit diff --ignore-all-space <file>to identify CRLF-only diffs — drop them withgit checkout --before staging. Real changes are the binary PNG diffs and any genuinely new/changed HTML content. - Visual spot-check each refreshed PNG: open the overlay in any
image viewer and confirm polylines trace the source curves cleanly.
Binary diffs are opaque to
git diff; the visual check is the only way to catch a buggy regenerator producing garbage that still tests-pass. - Stage only the real artifact changes (+ any genuinely missing
review pairs surfaced by the run). Commit as
chore(mtf):with a note on which extractor change drove the refresh.
Refresh stale digitization logs (baseline reset, #1249 / S177):
When algorithm drift accumulates across multiple PRs without a
log refresh, extract --check reports a large stale baseline (S177:
100 Tier 2 + 8 Tier 1). Don't blanket --accept everything —
verdict transitions HIGH→LOW would be silently swallowed. Two-stage
verdict-aware workflow:
- Branch:
chore/refresh-stale-logs(or scope-specific name). - Tier 1 first (lower risk —
log --allhas no gate):py -m mtfdigitizer.log --all. Inspect diffs; verify aggregate calibration (py -m mtfdigitizer.calibrate) is unchanged from the previous wrap-up. - Tier 2 Stage 1 — capture transitions, no writes. Capture
per-lens aggregate verdict using "LOW if ANY view LOW" (not
head -1). Re-run each stale lens individually, capture new aggregate verdict; tabulate transitions to a TSV. - Triage transitions. HIGH→HIGH and LOW→LOW: safe to batch
--accept. LOW→HIGH: improvement; safe to--accept. HIGH→LOW: real regression — investigate per-lens before accepting (overlay glance) or file a follow-up bug. S177 surfaced zero real HIGH→LOW. - Tier 2 Stage 2 — batch
--acceptonly after no real regressions confirmed:py -m mtfdigitizer.extract <slug> --acceptfor each still-stale lens. - Verify both
--checkcommands now exit 0; run mtfdigitizer pytest; runnpm run validate; commit aschore(mtf):with verdict-transition summary in the body. Probe scripts live inC:\tmp\perbase/quality.mdprobe-script convention — delete before commit.
Emit a digitized chart's readings as a TypeScript literal for the site:
cd tools && py -m mtfdigitizer.emit <slug> # measured (default)
cd tools && py -m mtfdigitizer.emit --mtf-type=computed <slug> # Sigma / Fujifilm
cd tools && py -m mtfdigitizer.emit <slug> <slug> # multipleBridges the digitizer's ExtractedChart Python output to the site's
MtfData shape in src/data/mtf-readings.ts. Prints the TS object
literal to stdout; per-field null counts to stderr. Paste into
mtf-readings.ts and npm run check && npm run build to verify.
The lens page picks it up via mtfReadings[slug] and renders via
MtfChart.astro (themed inline SVG + table). Closes the digitizer→site
loop ADR-038 §"Output" envisaged; the committed provenance SVG (from
mtfdigitizer.svg) stays an artifact, not the display source.
--mtf-type selects the mtfType field on the emitted entry: computed
for manufacturer optical-design charts (Sigma, Fujifilm), measured for
review-lab charts from a tested sample (LensTip, Optical Limits). Default:
measured (matches the campaign's majority case).
The slug must be in referenceset/charts.py with a populated plot_box;
the attribution URL is rendered from lenses.ts:officialUrl at page
render time (#1342 — no source field is emitted). For zooms (multi-view
charts via additional_views), the slug must also be in
_DEFAULT_FOCAL_LENGTHS with one mm value per view in primary-then-
additional order — emit raises a clear error if missing. Per ADR-033 every
published focal-length panel becomes its own MtfChart entry with
focalLength set; the detail page labels each block "f/X.X @ Nmm".
Per ADR-041 production-tier charts can emit with ground_truth=None.
Rows with all four fields null are dropped; MtfReading fields are
number | null (since the lens-page MTF rendering work) so the B2 None
contract flows through.
Stacked-aperture primes (Zeiss Touit family) declare ADR-065 role
labels ("max"/"stopped") in ReferenceChart.apertures, so the display
f-number per panel comes from _DEFAULT_APERTURES in emit.py (one
f-number per view, eye-read from the chart legend) — emit raises a
clear error when a multi-view chart has neither table entry. Tier 1
anchors emit through the per-cell GT gate (ADR-079): any cell whose
extracted value misses the maintainer's eye-read ground truth by more
than the ±0.05 calibration tolerance is nulled, with every gated cell
reported on stderr. Un-suppression is automatic — an extractor fix
that brings cells in-band ships them on the next emit. The
digitization log and provenance SVG intentionally keep showing the
extractor's real output for gated cells.
Audit spec field coverage per brand:
npx tsx scripts/audit-brand.ts # all brands summary
npx tsx scripts/audit-brand.ts Fujifilm # single brand detailFetch Fujifilm optical specs, images, and coatings (Playwright):
py tools/fujifilm/fetch_specs.py # fetch all missing data
py tools/fujifilm/fetch_specs.py --dry-run # list what would be fetched
py tools/fujifilm/fetch_specs.py --filter gf # filter by model substring
py tools/fujifilm/fetch_specs.py --specs-only # only extract specs text
py tools/fujifilm/fetch_specs.py --images-only # only download images
py tools/fujifilm/fetch_specs.py --coatings-only # only extract coatings
py tools/fujifilm/audit.py # audit data completeness
py tools/fujifilm/audit.py --missing # show only incomplete lensesFetch Samyang optical specs, MTF charts, and construction diagrams (urllib):
py tools/samyang/fetch_specs.py # fetch all (specs + images)
py tools/samyang/fetch_specs.py --dry-run # list lenses without fetching
py tools/samyang/fetch_specs.py --filter 12mm # filter by model substring
py tools/samyang/fetch_specs.py --specs-only # only extract specs text
py tools/samyang/fetch_specs.py --images-only # only download images
py tools/samyang/audit.py # audit data completeness
py tools/samyang/audit.py --missing # show only incomplete lensesFetch Samyang MTF charts:
py scripts/fetch-samyang-mtf.py # fetch all to docs/mtf-charts/
py scripts/fetch-samyang-mtf.py --seq 351 # fetch one by product seq
py scripts/fetch-samyang-mtf.py --dry-run # list without downloading
py scripts/fetch-samyang-mtf.py --temp # download to temp/ (testing)Fetch Sigma optical specs, MTF charts, and construction diagrams (urllib):
py tools/sigma/fetch_specs.py # fetch all (specs + images)
py tools/sigma/fetch_specs.py --dry-run # list lenses without fetching
py tools/sigma/fetch_specs.py --filter 12mm # filter by model substring
py tools/sigma/fetch_specs.py --specs-only # only extract specs text
py tools/sigma/fetch_specs.py --images-only # only download images
py tools/sigma/audit.py # audit data completeness
py tools/sigma/audit.py --missing # show only incomplete lensesFetch Viltrox optical specs and download images (Shopify JSON + HTML scraping):
py tools/viltrox/fetch_specs.py # fetch all specs from Shopify API
py tools/viltrox/fetch_specs.py --dry-run # list lenses without fetching
py tools/viltrox/fetch_specs.py --filter 13mm # filter by model substring
py tools/viltrox/download_images.py # download all theme images to cache
py tools/viltrox/download_images.py --dry-run # list image URLs only
py tools/viltrox/download_images.py --filter 27mm # filter by model substring
py tools/viltrox/audit.py # audit data completeness
py tools/viltrox/audit.py --missing # show only incomplete lensesFetch Tamron optical specs (urllib, dual-page parsing — main + /spec.html):
py tools/tamron/fetch_specs.py # fetch all specs + images
py tools/tamron/fetch_specs.py --dry-run # list lenses without fetching
py tools/tamron/fetch_specs.py --filter 17-70mm # filter by model substring
py tools/tamron/audit.py # audit data completeness
py tools/tamron/audit.py --missing # show only incomplete lensesFetch Tokina optical specs (urllib, alt-text scraping):
All 11 brands are migrated onto the pagefetch + brandkit architecture
(ADR-035) — brand parsing lives in tools/<brand>/extractor.py, the pipeline
in brandkit; fetch_specs.py/audit.py are thin delegators. The same
flags work for every brand (substitute the brand directory below). The
--verify flag (#779) cross-validates stored physical specs against the
official page — implemented for all brands except zeiss (N/A, PDF-only).
When using --verify to drive a lenses.ts data pass, confirm the
extractor is reliable first: a systematic extraction bug makes --verify
report false divergences and blindly applying its page values corrupts good
data (e.g. #906 truncated comma-formatted weights). Treat a physically
implausible page value as a tool bug, fix and re-verify; cross-check a
suspect official page (e.g. an /en-us/ page serving the wrong lens's
data) against /global/ or an independent source before overwriting.
Tokina shown as the example:
py tools/tokina/fetch_specs.py # fetch all specs + images
py tools/tokina/fetch_specs.py --dry-run # list lenses without fetching
py tools/tokina/fetch_specs.py --filter 23mm # filter by model substring
py tools/tokina/fetch_specs.py --verify # cross-validate physical specs (#779)
py tools/tokina/audit.py # audit data completeness
py tools/tokina/audit.py --missing # show only incomplete lensesFetch Carl Zeiss Touit PDF datasheets (discontinued lenses, no live pages):
py tools/zeiss/fetch_specs.py # download all PDF datasheets
py tools/zeiss/fetch_specs.py --dry-run # list lenses without downloading
py tools/zeiss/fetch_specs.py --filter 12mm # filter by model substring
py tools/zeiss/audit.py # audit data completeness
py tools/zeiss/audit.py --missing # show only incomplete lensesNote: Zeiss MTF charts and construction diagrams must be extracted manually from the downloaded PDFs.
Fetch TTartisan optical specs (urllib, spec table + prose parsing):
py tools/ttartisan/fetch_specs.py # fetch all specs + images
py tools/ttartisan/fetch_specs.py --dry-run # list lenses without fetching
py tools/ttartisan/fetch_specs.py --filter 23mm # filter by model substring
py tools/ttartisan/fetch_specs.py --specs-only # only extract specs text
py tools/ttartisan/fetch_specs.py --images-only # only download images
py tools/ttartisan/audit.py # audit data completeness
py tools/ttartisan/audit.py --missing # show only incomplete lensesNote: TTartisan pages use two site patterns — main site (ttartisan.com) with query-param routing and Shopify store (ttartisan.store). Some pages embed Shopify CDN images not detectable by the scraper. Some older pages use legacy timestamp URLs instead of named Specification-*.webp files. Construction diagrams are authoritative for special elements — page text often omits glass types that diagrams show.
Fetch Venus Laowa optical specs (SeleniumBase UC mode — Cloudflare bypass):
py tools/venus/fetch_specs.py # fetch all specs + images
py tools/venus/fetch_specs.py --dry-run # list lenses without fetching
py tools/venus/fetch_specs.py --filter 10mm # filter by model substring
py tools/venus/fetch_specs.py --specs-only # only extract specs text
py tools/venus/fetch_specs.py --images-only # only download images
py tools/venus/audit.py # audit data completeness
py tools/venus/audit.py --missing # show only incomplete lensesNote: venuslens.net uses Cloudflare Turnstile which blocks plain urllib and
Playwright. Requires py -m pip install seleniumbase (installs Undetected
Chrome driver). Runs headed (non-headless) — a browser window will open
briefly per page. Some construction diagrams use Chinese filenames not
detectable by the tool; manual page inspection may find additional images.
Extract MTF readings from chart PNGs:
Use tools/mtfdigitizer/ — unified digitizer per ADR-038. Requires Python
3.13+ with image-processing dependencies pinned in tools/requirements.txt
(opencv-python, numpy, scikit-image, Pillow, pytest, pytest-xdist).
Install: cd tools && pip install -r requirements.txt. CI installs the
same file to run the pytest suite on tools/** PRs (#1299).
Charts live per-lens under docs/optical-specs/<slug>/ (ADR-033) as
mtf-chart.{png,jpg}, or mtf-f<aperture>.png per aperture.
Five chart families have declared profiles today (Sigma, Samyang, 7Artisans, Tokina, Viltrox). Profile declaration is the authority; an unrecognized chart fails loud rather than being mis-traced (ADR-038 §1).
The end-to-end entry point is extract_chart(image_path, profile, plot_box, image_height_mm) (no CLI yet — callable from Python). See
tools/mtfdigitizer/README.md for the dispatch table and known limits.
Run the calibration suite against the reference set:
cd tools
py -m mtfdigitizer.calibrateList unscored lenses:
npx tsx scripts/list-unscored.ts # list all lenses without optical scoresCompute genre marks:
npx tsx scripts/compute-marks.ts print # print all computed marks
npx tsx scripts/compute-marks.ts patch # patch lenses.ts with marks- Verify the brand's full lineup — confirm every X-mount and GFX lens the brand offers before researching individual specs. Cross-reference official catalog, retailers (B&H, Adorama), and alikgriffin.com. Create a separate issue for any missing lenses.
- Audit gaps — run
npx tsx scripts/audit-brand.ts <Brand> - Run extraction tool if available:
py tools/<brand>/fetch_specs.py
- Generate research URLs —
py tools/lookup.py "<lens name>" - Check each source and record result in
specs-log.md(found / not found / 404 / paywall). Every specs-log MUST include a row for at least these five sources:- Official manufacturer page
- LensTip (
py tools/lenstip/search.py "<lens>"resolves the page ID) - Radojuva
- DPReview
- Google Image Search (construction diagram + MTF chart)
- If these five come up empty, continue down the source reference table below until data is found or all sources are exhausted
- Check Shopify JSON for embedded images — if the official site is
Shopify-based, fetch
<product-url>.jsonand extract image URLs frombody_html. Construction diagrams, MTF charts, and spec tables are often embedded as images invisible to text scraping. Download and visually inspect candidate images. - Verify EVERY field, not just optical ones — cross-check the full DB entry
against official + LensTip + B&H (+ retailer/press as needed), field by field:
optical (element/group counts, special elements, coatings) AND physical
(
weight,diameter,length,apertureBlades,filterThread,minFocusDistance,maxMagnification,year). The physical fields are where pre-existing rows hide the most errors — they are not optional. When auditing an existing lens, build a DB-value-vs-each-source table and resolve every mismatch; do not assume a field already present is correct.
- Update
specs-log.mdFIRST — the specs-log is the primary deliverable - Edit
lenses.ts— apply the verified data - Confirm both files updated before moving to the next lens
- Run
npm run validate - If adding
maxMagnificationto a scored lens, also addmacrogenre mark
- Audit false negatives —
py tools/lenstip/audit_specslog.py --fix - Rebuild LensTip index quarterly —
py tools/lenstip/build_index.py
| Source | Best for | Notes |
|---|---|---|
| Official manufacturer | Dimensions, filter thread, build features | Always check first |
| DPReview | Comprehensive specs incl. magnification | Archived but still available |
| LensTip | maxMagnification on budget lenses | Opaque numeric IDs — use the index |
| Radojuva | Hands-on magnification measurements | Multi-language, search all variants |
| digitalkamera.de | Dimensions | Rarely has magnification |
| cameradecision.com | Spec comparison | 403s on direct fetch, use Playwright |
| Mobile01 | Construction diagrams, Chinese brands | Requires SeleniumBase UC |
| Photography Life | Spec tables, diagrams, special elements | Predictable URL pattern |
| Dustin Abbott / Phillip Reeve | Trust-3 field measurements | Thorough optical analysis |
| Duclos Lenses | Cinema lens specs | Length, weight, min focus |
| Google Image Search | Construction diagrams, MTF charts | Text searches miss non-English sources |
-
LensTip IDs: URL names are ignored; only the numeric ID matters. Verify
ManufacturerandModelon the page — wrong IDs redirect silently. -
Image filenames: do not filter by filename keywords — news sites use generic names (e.g.
APO200F14-lens.jpgfor a combined diagram + MTF). For pages with < 10 images, visually check all content images. -
Mount variants: if lenses share optical design across mounts, update BOTH specs-logs.
-
Shopify section images (
/cdn/shop/files/): diagrams and MTF charts often live NOT in the product gallery (product.images[]) nor inlinebody_html, but as theme/section graphics referenced only in the rendered page HTML (filenames likeMTF_Template.jpg,01.png,50mm-F0_6.jpg). Scrape the full rendered HTML forcdn/shop/files/*.{jpg,png}and open EVERY one — do NOT sample a subset (a hand-picked range silently skips the one image that has the diagram/MTF). For a set of N images, download all and build a contact sheet to scan at once. Note the product page and the product gallery (product.images[]) are DIFFERENT image sets — check both. Also check the brand's official Amazon/regional store listings (US, SG, UK), which often host the highest-res official composite (diagram + MTF) and state coatings the.comstore omits. Request the largest image with?width=3200. -
Cropping the diagram/MTF out of a composite: use
tools/crop-artifact.py— do NOT hand-guess pixel coordinates (they silently truncate or off-center the result). It detects the content bounding box from the corner-median background:py tools/crop-artifact.py in.jpg --out construction-diagram.jpg # auto bbox py tools/crop-artifact.py in.jpg --split --left --out construction-diagram.jpg # left half of a side-by-side composite py tools/crop-artifact.py in.jpg --split --right --out mtf-chart.jpg # right half py tools/crop-artifact.py in.jpg --region 120,80,1480,1040 --out mtf-chart.jpg # explicit box when auto over/under-reaches py tools/crop-artifact.py in.jpg --check # advisory edge-touch report (non-blocking)
Always eyeball the output — a tight axis box or a wide lens housing legitimately reaches the margin, so
--checkis advisory only. -
Generation check (Mark II vs original): before trusting a "Mark II" row, confirm the entry's generation against the official page title and the lens's specs (not the URL slug alone). Rows created by copying the original frequently retain the original's
year,maxMagnification,apertureBlades, andfilterThreadwhile carrying Mark II optical construction. Verify each against the Mark II's own LensTip entry / spec table. Ensure the model name includes the generation marker per the naming convention. -
Source-conflict resolution: when LensTip (or any secondary source) contradicts the official page on construction, the official page wins. If a secondary source is wrong on one verifiable field, treat ALL its unverified fields as suspect (it may be mis-cataloged for the lens) — do NOT adopt its other figures. Leave a field unresolved and flagged rather than overwrite with a contradicted source.
-
Discontinuation: judge from the official
.jsstorefront endpoint (<product-url>.js→availableper variant), NOT the page's "Sold out" text — a sold-out variant is not a discontinued lens. All-variantavailable:false+ a "Used" listing + a successor model = discontinued (isDiscontinued: true). -
alikgriffin.com tables: AJAX-loaded (Ninja Tables plugin), not in page HTML. Use the admin-ajax API endpoint or ask the user to paste the table.
-
DuckDuckGo fallback: when Google or Bing block with CAPTCHAs, use DuckDuckGo HTML search — it works with plain urllib (~1s):
py -m pagefetch "https://html.duckduckgo.com/html/?q=<query>"(fromtools/)
The full quality gate runs all checks in sequence. Run before every commit:
npm run validatePipeline steps (exits on first failure):
| Step | Command | What it checks |
|---|---|---|
| Lint | npm run lint |
ESLint + sonarjs — code smells, unused vars |
| Format | npm run format |
Prettier — consistent style |
| Type check | npm run check |
astro check — .astro files and TypeScript types |
| Test | npm test |
Vitest — unit + component tests with coverage |
| Build | npm run build |
Astro build — full static site generation |
| Link check | npm run check:links |
Trailing slash validation on internal links |
Manual / scheduled (network — not in validate):
| Check | Command | What it checks |
|---|---|---|
| External links | npm run check:external-links |
Every external URL in src/data/*.ts (officialUrl + review links) is reachable. Bot-blocked/rate-limited hosts (403/429/503/timeout) report as UNVERIFIABLE, not dead. Runs weekly via external-links.yml; also a pre-release check (§5.1). |
Every commit is gated by .husky/pre-commit. Runs automatically — no manual
step needed.
What runs:
gitleaks protect --staged— blocks commits containing secrets (skipped if gitleaks is not installed locally)lint-staged— runs per file type on staged files only:*.{ts,tsx}—eslint --fix+prettier --write*.astro—prettier --write*.{json,md,css}—prettier --write
Configuration: lint-staged key in package.json, hook in .husky/pre-commit.
Prettier owns all formatting — no style debates in code review. Runs at all
three quality gate layers (editor on save, pre-commit via lint-staged, CI via
npm run format).
npm run format # check (CI mode, exits non-zero on diff)
npx prettier --write <file> # fix a single fileConfiguration: .prettierrc + prettier-plugin-astro for .astro files.
Validates .astro files, TypeScript types, and content schemas. Part of the
validate pipeline.
npm run checkCatches type errors that ESLint and the TypeScript compiler miss in .astro
files — Astro's template syntax requires its own checker.
Run tests (single run with coverage):
npm testCoverage text summary prints to the console. Threshold enforcement: statements 85%, branches 80%, functions 90%, lines 85%.
Watch mode (development):
npm run test:watchGenerate local reports (coverage + test results):
npm run test:reportGenerates:
reports/tests/index.html— interactive HTML test report (pass/fail, timing, filter by file)reports/coverage/index.html— interactive HTML coverage map (click into files, see uncovered lines)
Open either in a browser. The reports/ directory is gitignored.
Writing tests:
- Test factories in
src/test/factories.ts— usemakeLens,makeExplorerLens,makeCamerainstead of inline objects makeExplorerLensmirrors theExplorerLensshape (booleans default toundefined, matching real data)- Use
getAllByTextfor explorer content (table + cards both render) - Use
getByRole("combobox", { name: /label/i })for select filters - Use
screen.getAllByRole("button", { name: "Yes" })for boolean chip filters
Test structure:
| File | Covers |
|---|---|
src/utils/scoring.test.ts |
Genre scoring, OQ, helpers, field picker |
src/utils/formatting.test.ts |
Shutter + FL formatting |
src/utils/slug.test.ts |
URL slug generation |
src/hooks/useSort.test.ts |
Sort hook (strings, numbers, booleans, nulls) |
src/hooks/useUrlFilters.test.ts |
URL filter sync hook |
src/data/lenses.test.ts |
Data validation (uniqueness, ranges, booleans) |
src/data/genres.test.ts |
Genre config validation |
src/components/interactive/LensExplorer/LensExplorer.test.tsx |
Lens filters + sort |
src/components/interactive/CameraExplorer/CameraExplorer.test.tsx |
Camera filters + sort |
src/components/interactive/GenreGuide/GenreGuide.test.tsx |
Genre tabs, filters, matrices |
src/components/interactive/GenreGuide/exposure.test.ts |
Exposure calculations |
SonarQube-equivalent code smell detection runs via ESLint at all three quality
gate layers (editor, pre-commit, CI). Type-checked linting is enabled via
projectService — 67 sonarjs rules that require type information are active.
Configuration is in eslint.config.js.
Explicitly enabled rules:
| Rule | What it catches |
|---|---|
cognitive-complexity |
Functions exceeding complexity threshold |
no-nested-conditional |
Deeply nested if/switch statements |
no-nested-template-literals |
Template literals inside template literals |
redundant-type-aliases |
Type aliases that add no information |
unicorn/no-zero-fractions |
Unnecessary .0 in numeric literals |
unicorn/prefer-number-properties |
parseInt() instead of Number.parseInt() |
Also enforced via ESLint core: max-depth: 3, no-console.
Also enforced via sonarjs.configs.recommended: 67 type-checked rules including
no-alphabetical-sort, prefer-read-only-props, prefer-regexp-exec.
npm run lintLighthouse runs automatically on every PR that affects the served bytes,
against 4 key pages (/, /lenses/, /cameras/, /genre/).
Configuration is in lighthouserc.json.
PRs that change only package.json and package-lock.json (e.g.
Dependabot bumps) skip the Lighthouse job — the build still runs to
verify deps compile, but Lighthouse does not. See ADR-039 for the
rationale. Code, content, workflow, and lighthouserc.json changes
continue to trigger Lighthouse as before.
| Category | Threshold | Level |
|---|---|---|
| Performance | >= 80 | error |
| Accessibility | >= 90 | warn |
| SEO | >= 90 | warn |
| Best Practices | >= 90 | warn |
Run locally:
npm run lighthouseThis builds the site and runs Lighthouse against all 4 pages (3 runs each).
HTML reports are written to reports/lighthouse/ — open any .report.html
in a browser for full scores, diagnostics, and opportunities.
Lychee checks for broken internal links in the built site. Runs in CI on every PR. Requires lychee installed locally (see ONBOARDING prerequisites).
npm run build
lychee --offline --no-progress --root-dir dist dist/Checks all internal links in the static output. Exits non-zero on broken links.
Gitleaks scans for accidentally committed secrets. Runs in CI on every PR. Requires gitleaks installed locally (see ONBOARDING prerequisites).
gitleaks detect --source . --config .gitleaks.tomlScans the full repo history. Exits non-zero if secrets are found.
CodeQL runs automatically on every PR via .github/workflows/codeql.yml.
Scans JavaScript and TypeScript for security vulnerabilities and code quality
issues. No local setup needed — GitHub-native, results appear in the Security
tab.
Dependabot opens weekly PRs for outdated npm packages and GitHub Actions.
Configuration is in .github/dependabot.yml. PRs are labeled chore + P3.
Review Dependabot PRs:
gh pr list --author app/dependabotWeekly queue triage. When ten-ish PRs are stacked, fast-scan CI per PR to separate green-and-safe from broken:
for pr in <id> <id> ...; do gh pr checks $pr; echo; doneThis surfaces failing PRs in seconds; the failure type (build vs links vs gate) is visible without opening the run page.
Sibling lockfile-conflict cascade. Each squash-merge bumps
package-lock.json, so every other open PR with a lockfile change
conflicts after the first merge. Plan for it: merge a batch in parallel
(2-3 simultaneous gh pr merge calls — one or two will lose the race),
then gh pr comment <N> --body "@dependabot rebase" on each conflicted
loser, wait for rebase + CI, merge. Distinct from the co-dependent-bumps
pathology below — here each PR is independent, just lockfile-coupled.
Fix-PR-first for lint-rule bumps. When a bump adds an ESLint rule
that flags existing source (e.g. sonarjs 4.1.0's prefer-specific-assertions):
- Write the fix in the older rule's API on a separate branch — usually
forward-compatible (
expect(x).toHaveLength(N)works on both old and new sonarjs). One reviewable PR, one bump PR, two clean diffs. - Merge the fix-PR to main.
gh pr comment <bump-PR> --body "@dependabot rebase"so the bump rebases against new main and CI goes green.
Do NOT push the fix directly onto Dependabot's branch — keeps the bump as Dependabot's own clean commit, recreatable on future weekly runs without surprise.
Co-dependent bumps. package.json uses ^ ranges, so the lockfile pins the actual version. When Dependabot splits interlocked packages (e.g. react and react-dom) into separate PRs, each PR's CI runs in isolation against current main — but merging one alone shifts the lockfile and breaks main with Incompatible versions on the next merge.
Three resolution paths, in order of preference:
- Group them in
.github/dependabot.ymlso Dependabot ships a single multi-bump PR going forward. Add agroups:block under the npm ecosystem (current config groups the react ecosystem this way; copy that pattern for any new interlocked set):groups: react: patterns: - react - react-dom - "@types/react" - "@types/react-dom"
- Rebase a single Dependabot PR with
gh pr comment <N> --body "@dependabot rebase"ONLY if the PR already covers all interlocked packages but is just behind main. Rebase will NOT add a missing package to a single-package PR — Dependabot's file scope is fixed by its grouping config at PR creation time. - Manual combined bump when (1) wasn't in place at creation time and (2) doesn't apply. Branch off main,
npm install <pkgA>@<v> <pkgB>@<v> <types>@<v>together, runnpm run validate, open one PR closing the split Dependabot PRs as superseded:Then add thegit checkout -b chore/<pkg>-<version> npm install <pkgA>@<v> <pkgB>@<v> npm run validate # commit, push, PR gh pr close <split-PR-A> --comment "Superseded by #<new>" --delete-branch gh pr close <split-PR-B> --comment "Superseded by #<new>" --delete-branch
groups:entry per (1) so it doesn't recur.
Blocked-upstream major bumps. A major bump can fail npm ci with
ERESOLVE because a peer dependency has not widened its range to accept
the new major (e.g. TS 6->7 #1407: @astrojs/check peers
typescript@"^5.0.0 || ^6.0.0", no published version accepts 7). This
is an upstream block, not our source — do not force it with
--legacy-peer-deps. Defer it:
- Confirm the block is upstream: the failing job's log shows
npm error code ERESOLVEnaming a peer range that excludes the new major, not a compile error in our code. - Comment the block reason on the PR, referencing the tracking issue.
gh pr comment <N> --body "@dependabot ignore this major version"— Dependabot closes the PR and stops re-proposing that major until you@dependabot unignore.- File a Backlog issue with the trigger condition (the blocking peer publishes a version whose range includes the new major), per the deferred-work format.
Umami Cloud tracks page views with zero cookies and no consent banner.
The script loads from cloud.umami.is via the base layout. Run this
manual check after changes to navigation, View Transitions, or the
analytics integration.
Prerequisites:
- Access to https://cloud.umami.is/ (login credentials)
- Chrome DevTools (or equivalent)
Step 1 — Script loads:
- Open https://wuseria.com
- DevTools → Network → filter
script.js - Refresh the page
Expected: cloud.umami.is/script.js returns 200 OK, ~5-6 KB.
No console errors.
Step 2 — Page views register:
- Open the Umami dashboard → select wuseria.com → Realtime view
- Navigate through several pages: Lenses → Cameras → Genre → Wiki → any individual lens detail page
- Check the dashboard
Expected: each page path appears (/lenses/, /cameras/, /genre/,
/wiki/, /lenses/[slug]). Count matches pages visited.
Step 3 — No duplicate events:
- DevTools → Network → clear the log
- Click one navigation link (e.g. Cameras)
- Filter by
cloud.umami.is
Expected: exactly 1 send request per navigation. If 2+ fire
for a single click, that's a duplicate event bug.
Step 4 — Fresh page load:
- Clear the Network log
- Type
wuseria.comin the address bar and press Enter (full load) - Filter by
cloud.umami.is
Expected: script.js + exactly 1 send request.
Last verified: 2026-05-03 — all checks pass.
GSC surfaces indexing issues that require code fixes (e.g. trailing slash inconsistencies, excluded pages, crawl errors). Run this check after routing changes, new page types, or sitemap updates — and periodically (~monthly).
Prerequisites:
- Access to Google Search Console for wuseria.com
Step 1 — Coverage check:
- Open GSC → Pages
- Check "Not indexed" count and reasons
Red flags: "Page with redirect", "Duplicate without user-selected canonical", "Excluded by noindex tag", "Crawled — currently not indexed" on pages that should be indexed.
Step 2 — Sitemap validation:
- Open GSC → Sitemaps
- Verify
sitemap-index.xmlstatus is "Success" - Compare submitted page count with expected (currently 461 pages)
If count is off, check that @astrojs/sitemap is generating correctly and
no pages are accidentally excluded.
Step 3 — URL inspection:
For new page types or routing changes, inspect a sample URL:
- GSC → URL Inspection → paste the URL
- Verify: "URL is on Google" or "URL can be indexed"
- Check canonical URL matches the page URL
Step 4 — Core Web Vitals:
- GSC → Core Web Vitals
- Check for "Poor" or "Needs improvement" URLs
Any regression is a bug — cross-reference with Lighthouse (3.7) to identify the cause.
Last verified: 2026-05-14 — 106/461 pages indexed (23%), no critical coverage issues.
PageSpeed Insights complements Lighthouse CI (3.7) with real-user field data from the Chrome User Experience Report (CrUX). Local Lighthouse runs produce lab data only — PSI adds how actual visitors experience the site. Run after layout changes, new page types, or when GSC (3.13) flags Core Web Vitals regressions.
Step 1 — Test key pages:
Run these URLs through https://pagespeed.web.dev/:
https://wuseria.com/https://wuseria.com/lenses/https://wuseria.com/genre/- A sample lens detail page (e.g.
https://wuseria.com/lenses/xf-23mm-f1-4-r/)
Step 2 — Check field data (CrUX):
If field data is available, verify Core Web Vitals pass:
| Metric | Good threshold |
|---|---|
| LCP | < 2.5s |
| INP | < 200ms |
| CLS | < 0.1 |
If field data shows "Not enough data", rely on lab data only.
Step 3 — Review lab diagnostics:
Check the Opportunities and Diagnostics sections for:
- Render-blocking resources
- Unused JavaScript/CSS
- Image optimization opportunities
- Layout shift sources
Any performance score below 80 is a bug — cross-reference with Lighthouse CI (3.7) to confirm it reproduces locally before fixing.
Last verified: not yet baselined.
Screaming Frog SEO Spider is a desktop crawler that audits the site like a search engine. Catches redirect chains, orphan pages, canonical mismatches, and structured data errors. Free for up to 500 URLs (site currently has 461 pages).
Prerequisites:
- Screaming Frog SEO Spider installed locally
- Free tier limit: 500 URLs (site currently has 461 pages). When the site
exceeds 500, either buy a license or crawl selectively by filtering to
specific directories (e.g.
/lenses/,/wiki/)
Step 1 — Crawl the site:
- Open Screaming Frog
- Enter
https://wuseria.com/ - Start crawl — wait for completion
Step 2 — Check key reports:
| Tab | What to check |
|---|---|
| Internal | Response codes — no 3xx chains, no 4xx/5xx |
| Page Titles | No missing, duplicate, or truncated titles |
| Meta Description | No missing or duplicate descriptions |
| H1 | Exactly one H1 per page, no duplicates across pages |
| H2 | No skipped heading levels (H1 → H3) |
| Canonicals | Every page has a self-referencing canonical |
| Structured Data | JSON-LD validates, no errors |
| Images | No missing alt text on content images |
Step 3 — Check for orphan pages:
- Crawl Analysis → Orphan Pages
- Cross-reference with sitemap (Configuration → Spider → Crawl → check "Crawl linked XML Sitemaps")
Any page in the sitemap but not linked internally is an orphan — fix the internal linking or remove from sitemap.
Step 4 — Export issues:
Export findings as CSV for tracking. Issues with direct code fixes (missing meta, broken canonicals, redirect chains) are bugs.
Last verified: not yet baselined.
Periodic checklist covering GSC, Umami, Lighthouse, structured data, and
technical SEO spot checks. See docs/audits/seo-test-plan.md.
git submodule update --remote docs/solid-ai-templates
git add docs/solid-ai-templates
git commit -m "chore: bump solid-ai-templates submodule"- Create an ADR in
docs/decisions/using the format: context, decision, alternatives, consequences - ADRs are immutable once merged — create a new ADR to supersede an old one
All prototype resources live in docs/prototype/. To run:
cp docs/prototype/index.html index.html
cp docs/prototype/main.jsx src/main.jsx
cp docs/prototype/App.jsx src/App.jsx
npm run devOpen http://localhost:5173. Clean up after:
rm index.html src/main.jsx src/App.jsxThe prototype uses old field names (pre-migration) and is kept for reference only. Do not commit the copied files.
Per base/git.md, complete these gates first — do not tag with any
critical finding unresolved:
git branch --no-merged main # investigate any unmerged work
git fsck --unreachable --no-reflogs | grep commit # verify no orphaned commits lost
npm run check:external-links # external officialUrl/review links (0 dead)- Unmerged branches / orphaned commits — investigate any results;
confirm no unique work would be lost by tagging the current
main. - External link check —
npm run check:external-linksmust report0 dead(unverifiable bot-blocked hosts are acceptable). The weeklyexternal-links.ymlcron also runs this. - 360-degree analysis — run a fresh 360 (see
base/workflow/360.md, four parallel role agents) and record the result as a verbose dated report indocs/audits/YYYY-MM-DD-360.md(per-dimension findings tables + the rationale for each grade — not just a score table). The release SHOULD NOT ship with critical findings unresolved; non-critical findings become issues. (This project stores dated audit reports underdocs/audits/rather than a singledocs/360-audit.mdhistory file — see the upstream deviation note in CLAUDE.md §5.2.) - Structure audit — verify the §5.2 MUSTs (standard docs, README sections, config/SEO files) if the project structure changed since the last audit.
# From main, with clean working directory:
npm run release -- A.B.CThe script validates preconditions (on main, clean tree, valid semver),
bumps package.json, commits, pushes, and creates a PR. After the PR
merges:
git checkout main && git pull
git tag vA.B.C && git push origin vA.B.C
# Verify the manifest version matches the tag (base/git.md post-release check)
node -p "require('./package.json').version" # must equal A.B.C
git describe --tags --abbrev=0 # must be vA.B.C
# Create the GitHub Release — a pushed tag alone does NOT create one
gh release create vA.B.C --title "vA.B.C" --generate-notes
# Clean up the release branch
git branch -d chore/release-vA.B.C
git push origin --delete chore/release-vA.B.CThe git tag and the GitHub Release are separate artifacts: pushing
the tag creates the git ref; gh release create creates the Releases-page
entry with notes auto-generated from the PRs merged since the previous tag.
The deploy (§5.2) runs on the release-bump merge to main, independent of
the tag.
Deployment is automated via GitHub Actions on push to main. No manual steps
required.
The deploy job self-retries transient actions/deploy-pages failures
(Deployment failed, try again later.) up to three attempts with growing
backoff (30s, then 90s) before failing (ADR-078). If a deploy still goes
red, it is either a real build/validate failure (fix the diff) or a
sustained GitHub Pages backend outage that outlived the retries. To tell them apart: check the deploy job — if build
is green and only deploy failed at actions/deploy-pages, it is the
backend. The live site is unaffected (Pages keeps serving the last good
deployment — confirm with curl -I https://wuseria.com/); once the backend
recovers, clear main-red with gh run rerun <run-id> --failed (reuses the
build artifact, re-exercises the retry).