CohortScope: can a model tell a Rembrandt from a Rembrandt pupil?
Report date: 2026-08-23
Repository: https://github.com/JialaiYing/CohortScope
Findings page: results/dossier/index.html
Answer: No, and we can say precisely why not.
1. Summary
CohortScope ranks Rijksmuseum Rembrandt oil paintings by how visually anomalous they look against a cohort of firmly attributed works, using a pretrained ResNet50 embedding (Signal A) and eight handcrafted texture and colour statistics (Signal B). The idea was that works by pupils and workshop members should score as outliers, giving curators a cheap triage list before expensive physical examination.
It does not work. Five pre-registered held-out tests:
| Test | Question | Works ranked | AUC | Verdict |
|---|---|---|---|---|
| O04 | Does one circle/workshop work clear the cohort 95th percentile? | 1 | n/a | weak |
| O06 | Does the ranking separate 67 documented Rembrandt pupils? | 90 | 0.419 | fail |
| O09 | Does Signal B work once every pixel means 0.20 mm of canvas? | 55 | 0.469 | fail |
| O11 | Does Signal A work at 0.20 mm/px with no resize and no crop? | 52 | 0.523 | fail |
| O13 | Was 0.20 mm/px simply the wrong scale? Sweep 0.15 to 0.30. | 40 and 35 | 0.453 to 0.530 | fail |
Chance is 0.500. Every one of the eight sweep points sits within 0.047 of chance, and none clears even an uncorrected 95% confidence bar, let alone the Bonferroni-corrected bar the design locked in advance.
The finding that does hold up is the reason. In all four pupil tests, a single column of acquisition metadata separated the two classes better than the entire pipeline did:
| Test | Pipeline AUC | Metadata column | Its AUC |
|---|---|---|---|
| O06 | 0.419 | mm_per_px_analyzed |
0.590 |
| O09 | 0.469 | mm_per_px_native |
0.689 |
| O11 | 0.523 | mm_per_px_native |
0.705 |
| O13 | 0.530 (best of 8) | mm_per_px_native |
0.617 |
Millimetres of canvas per pixel is a property of how the museum photographed the object, not of how the artist painted it. Whatever weak structure the model was picking up, it was closer to camera setup than to brushwork. That diagnosis is what produced the one genuinely reusable artifact in the project: a per-work verdict on whether a given painting can be analysed from published imagery at all.
2. The question, and why it is harder than it looks
Museums publish open, high-resolution images and rich metadata, but they have no cheap, explainable way to flag which works in an attribution group might merit re-examination. CohortScope narrows that to one museum and one artist:
Relative to Rijksmuseum oils currently attributed to Rembrandt van Rijn, which works look statistically unusual, and which measurable signal drove the flag?
The attribution literature says the discriminating evidence is largely in the handling: stroke width, loading, directional consistency, the physical trace of the hand. A seventeenth-century brushstroke is roughly 0.3 to 3 mm wide. That number turns out to be the whole story, and the project did not know it at the start.
The trap is that image files hide it. A 1500 px wide JPEG looks like a 1500 px wide JPEG whether the painting is a 15 cm panel or a 4 m canvas. The pixels are commensurable; the things they depict are not. Standard practice, resize the short edge to 256 and centre crop to 224, silently makes the problem worse, and does so differently for every object. Nothing in a normal CV pipeline surfaces this, and no accuracy metric distinguishes "the model cannot separate these classes" from "the model was never shown the evidence".
3. Methodology
3.1 Data
Rijksmuseum open collection API, no key required. Filters locked in config.py and
never changed after the first harvest: type=painting, material=oil paint,
imageAvailable=true. Images arrive over IIIF.
108 scored works. Splits are assigned by priority rules in acquire.py from catalogue
text, not by hand:
| Split | N | Rule | Role |
|---|---|---|---|
cohort |
23 | firmly attributed to Rembrandt | fits the statistical normals; negative class |
validation |
1 | title or attribution contains circle / workshop / school | held out for O04 |
ambiguous |
1 | "attributed to" | scored, never fitted, never counted |
pupil (Tier 1) |
67 | creator matches a documented pupil roster | positive class for O06 onward |
pupil (Tier 2) |
16 | associates rather than documented pupils | sensitivity only, never pooled |
Four works on the pre-registered pupil roster did not make it in. Three (SK-C-371,
SK-C-1598, SK-A-1627) were already claimed as excluded/other_artist by a Phase 1
probe, and the pre-registration forbids re-splitting a claimed work. One (SK-A-4034)
returned IIIF 400 and failed closed. Reading the rule literally cost three usable
samples. Amending it after seeing which three would have been a post-hoc edit to a
pre-registered rule, so it was not done, and the loss is in the report instead.
3.2 Pipeline
Stages are flat modules at the repository root and run in order. Each has a frozen
RECIPE_ID that names its output directory and is written into a manifest.json.
Downstream stages assert the upstream recipe ID and take their worklist from that
manifest, never from a filesystem glob or a fresh SQLite query.
acquire Search API, Linked Art, IIIF → SQLite + JPEGs
preprocess two disjoint branches → RGB PNG (branch H) and 224 tensors (branch C)
embed ResNet50, penultimate layer → 2048-d vectors
features 8 handcrafted scalars → CSV
score cohort normals, LOO → ranked table + O04
The two preprocessing branches never cross. Branch H is EXIF-corrected identity RGB and
its only consumer is features.py. Branch C is resized, cropped and ImageNet-normalised
and its only consumer is embed.py. Handcrafted features are therefore never computed
on tensors that a CNN transform has already distorted.
3.3 The two signals
Signal A, the embedding channel: cosine distance from a work's L2-normalised ResNet50
embedding to the cohort centroid, converted to z_A.
Signal B, the interpretable channel: eight scalars chosen to be readable by a human
rather than to maximise anything. grad_mag_mean, grad_mag_std,
grad_orient_entropy, laplacian_var, lbp_entropy, glcm_contrast,
lab_chroma_mean, hue_circ_std. Each is z-scored against the cohort and z_B is the
RMS of the eight.
combined = z_A + z_B. There is deliberately no learned weighting between them and no
single opaque score: every row keeps its per-signal values and its named top drivers, so
a flag can always be read as "flagged because of gradient magnitude variance", not just
"flagged".
3.4 Fit rules
Normals are fitted on split=cohort only. Cohort works are scored leave one out:
the centroid and the mean/standard deviation used to score work i are computed from the
other 22 cohort works, so no painting contributes to the distribution it is measured
against. Validation, ambiguous and pupil works use the full-cohort statistics and never
enter a fit.
This is enforced structurally rather than by convention. Every fit path in score.py
branches on meta[oid]["split"] == "cohort", so a split added later is non-fitting by
construction and cannot leak in by someone forgetting to filter.
3.5 Evaluation protocol
Held-out separation is measured as ROC AUC between cohort (negative) and Tier 1 pupils (positive), equivalent to a Mann-Whitney U with tie handling. Confidence intervals are stratified percentile bootstrap, 10,000 resamples, with the seed written into the design document before the data existed (20260819 for O06, 20260822 for O09, 20260823 for O11, 20260824 for O13). Precision@k is reported at k = 5, 10, 20 against the base rate, since a triage tool that scores below base rate is worse than a random shortlist.
Outcome tiers were fixed in advance: fail if the CI lower bound is at or below 0.50,
pass if AUC is at or above 0.70, weak otherwise.
4. Results and analysis
4.1 O04, the first held-out check: weak
SK-A-3934 (Borstbeeld van een lachende jonge man), the only circle/workshop work in
the corpus. combined = 0.2826, rank 10 of 25. Cohort median is -0.1168 and the
pre-registered pass bar, cohort p95, is 2.1069. It clears the median and does not come
close to p95, which is weak by the locked rule.
Read honestly, this is one sample. It is not recovery of a clear anomaly and it is not a
soft pass. The embedding channel was essentially null (z_A = 0.023) and what little
signal there was came from Signal B. The correct response to a weak at N=1 is more
held-out data, which is what came next.
4.2 O06, the first real test: fail
Harvesting works by documented Rembrandt pupils raised held-out N from 1 to 67.
| Quantity | Value |
|---|---|
AUC (combined, 23 cohort vs 67 Tier 1) |
0.4192 |
| bootstrap 95% CI | [0.2686, 0.5775] |
z_A alone |
0.4270 |
z_B alone |
0.5224 |
Both signals are at chance and the combination is below it. Precision@k sits under the
base rate at every k, meaning the ranking would give a curator a worse shortlist than
drawing at random. The pre-registered confound check then found that
mm_per_px_analyzed alone separated the classes at AUC 0.590, better than the whole
two-signal pipeline.
That last number is what turned the project from "the method failed" into "find out why". A confound that beats the model is not noise; it is a pointer.
4.3 The resolution audit: what the images actually carried
dimensions.py records catalogued canvas size (cm_width, cm_height), native IIIF
pixel dimensions, analysed pixel dimensions, and the derived mm_per_px_native and
mm_per_px_analyzed, for all 108 works. All columns are nullable on purpose: the museum
does not catalogue a size for every object, and "unknown" has to stay distinguishable
from "fine".
| Stage | mm of canvas per pixel | works finer than 0.30 mm/px |
|---|---|---|
| native IIIF, as published | 0.015 to 0.812 | 85 / 108 |
analysed derivative (features_v1) |
0.100 to 3.467 | 19 / 108 |
CNN input (embed_v1) |
0.586 to 16.058 | 0 / 108 |
Three things fall out of this table.
Not one painting reached brushstroke scale at the CNN. Zero of 108. Signal A scoring 0.427 was never a fair test of the embedding; it was a test of an embedding that had never been shown the evidence.
The analysed corpus varied 35-fold in physical scale. Two works with identical pixel dimensions could differ by more than an order of magnitude in what a pixel covered. Any texture statistic computed across that corpus was comparing incommensurable quantities.
Only 6.3% of the published resolution was ever downloaded. The information was sitting on the museum's server the whole time. This is not a museum problem, it is a pipeline problem, and it is almost certainly not unique to this project.
4.4 Removing the confound: fetch area, not pixels
IIIF serves arbitrary regions at arbitrary sizes, so a patch of known physical size can
be requested directly without downloading a gigapixel file. tiles.py takes 20 tiles per
painting, each covering 30 mm × 30 mm of canvas delivered at 150 × 150 px. Every tile
is 0.20 mm/px, on a 15 cm panel and on a 4 m canvas alike. Tile positions are evenly
spaced over the row-major grid with no RNG, so the same database yields the same tiles
every run.
The floor is a real constraint. A painting whose native resolution is coarser than 0.20 mm/px cannot supply the tile, and asking IIIF for it anyway would make the server upsample, inventing detail that was never photographed. Those works are reported as below floor and not scored. 64 of 108 works clear the floor; 44 do not, including six of the physically largest firm Rembrandts and the Night Watch at 0.310 mm/px native. The cohort drops from 23 to 17.
Losing the Night Watch to your own eligibility rule is uncomfortable, and it is the correct behaviour. Scoring it on inadequate pixels would produce a number, and the number would be meaningless.
4.5 O09, Signal B on commensurable pixels: fail
tile_features.py calls the same features.extract_one() with the same constants as
features_v1. The only difference between the two arms is what a pixel means, which is
the experimental control.
| Arm | pixels | AUC |
|---|---|---|
tile_scores_v1 |
0.20 mm/px everywhere | 0.469 |
features_v1 re-fit on the same 55 works |
fixed 1500 px wide, 0.100 to 0.947 mm/px | 0.427 |
| ΔAUC | +0.042, 95% CI [-0.141, +0.223] |
The baseline is re-fitted from scratch on the same population rather than quoted from
O06, so the two arms differ in exactly one thing. Physical normalisation moved the number
by less than its own confidence interval. mm_per_px_native scored 0.689 on this
population, again beating the pipeline.
One methodological note that looks like an edge case and is not. hue_circ_std is
undefined on 77 near-grey tiles. Dropping those tiles would have been content-based
filtering with a class-correlated rate (7 of 17 cohort works affected versus 11 of 38
Tier 1), which the design forbids. The rule written before any aggregate was computed:
keep the tile, exclude that one cell from that one feature's median. This was decided and
committed before the AUC existed, which is the only reason it is credible.
4.6 O11, Signal A on commensurable pixels: fail
The obstacle O09 named was that a 150 px tile cannot enter a 224 px backbone without a
resample factor, which is exactly the arbitrariness the tiling exists to remove. The
resolution is arithmetic, not a choice: 224 px (fixed by the backbone) × 0.20 mm/px
(the locked floor) = 44.8 mm. The region arrives at the backbone's native input size,
so tile_embed.py applies only ImageNet normalisation. No resize, no crop, no
interpolation. It deliberately does not call the shared CNN transform, whose 256-resize
and 224-crop are precisely what makes the analysed mm/px vary per work.
| Arm | AUC |
|---|---|
tile_scores_a_v1 (z_A_tile) |
0.523 |
embed_v1 re-fit on the same 52 works |
0.391 |
| ΔAUC | +0.132, 95% CI [-0.092, +0.352] |
This is the largest movement any change in the project produced, and it still means very
little. The fixed-pixel arm was clearly below chance; commensurable pixels brought it
back to chance. The interval contains zero. Precision@5 is 0.200 against a base rate of
0.692. mm_per_px_native scored 0.705, with Spearman rho of -0.422 against the score
itself, which is the same confound firing harder.
The larger tile costs 3 works, and interestingly they are the physically smallest
(SK-A-3982, SK-A-88, SK-A-89), whereas the D34 exclusions were the largest. The two
tile recipes are size-biased in opposite directions. Because Signal A and Signal B now
live on different populations (52 and 55 works), there is deliberately no combined
score on either tile recipe. Summing z-scores across different corpora would be
meaningless.
4.7 O13, the resolution sweep: fail
The last live hypothesis was that 0.20 mm/px was simply the wrong scale. sweep.py
re-ran both signals at 0.15, 0.20, 0.25 and 0.30 mm/px, holding each signal's pixel count
fixed so that only millimetres per pixel varied.
Signal B, 150 px tiles, 16 cohort vs 24 Tier 1, base rate 0.600:
| floor | tile canvas | AUC | 95% CI | corrected CI |
|---|---|---|---|---|
| 0.15 | 22.5 mm | 0.466 | [0.286, 0.651] | [0.224, 0.716] |
| 0.20 | 30 mm | 0.474 | [0.292, 0.656] | [0.224, 0.724] |
| 0.25 | 37.5 mm | 0.484 | [0.286, 0.677] | [0.224, 0.750] |
| 0.30 | 45 mm | 0.495 | [0.310, 0.682] | [0.242, 0.757] |
Signal A, 224 px tiles, 15 cohort vs 20 Tier 1, base rate 0.571:
| floor | tile canvas | AUC | 95% CI | corrected CI |
|---|---|---|---|---|
| 0.15 | 33.6 mm | 0.453 | [0.250, 0.667] | [0.170, 0.760] |
| 0.20 | 44.8 mm | 0.530 | [0.310, 0.757] | [0.230, 0.838] |
| 0.25 | 56 mm | 0.503 | [0.283, 0.720] | [0.197, 0.810] |
| 0.30 | 67.2 mm | 0.473 | [0.263, 0.690] | [0.180, 0.773] |
All eight corrected intervals contain 0.50, and so do all eight uncorrected ones. The multiplicity correction never had to do any work, because not a single point cleared even the unadjusted bar. Signal B spans 0.029 across a 2× change in resolution. The curves are flat, not noisy-but-trending.
Two design choices here are load-bearing and easy to get wrong.
The population is fixed across floors, not re-derived per floor. Eligibility is not monotonic in the floor: a coarser floor admits more works by the mm/px test but excludes more by the "20 tiles must fit on the canvas" test. Re-deriving the population per floor would confound resolution with which paintings entered the sample, and the resulting curve would be uninterpretable. Holding it fixed costs sample size (the 0.05 to 0.40 intersection is 6 works for Signal B and zero for Signal A, which is why the range is 0.15 to 0.30) and buys the only version of the experiment that answers the question asked.
Multiplicity is corrected, and corrected in advance. Four floors × two signals = 8 tests. Reading the best of eight against an uncorrected 95% interval inflates the false-positive rate to roughly 34%. The design locked a Bonferroni correction to a 99.375% interval before any point existed, and both intervals are computed from the same bootstrap draws at every point, so the correction cannot be applied selectively to a winner.
4.8 What the three escape hatches were, and how each closed
| "It failed because..." | Test | Outcome |
|---|---|---|
| ...N was too small | O06 raised held-out N from 1 to 67 | got worse, 0.419 |
| ...the images were incommensurable | measured it: 35× spread, 0/108 at brushstroke scale | confound was real |
| ...so fix the scale and it will work | O09 and O11 on 0.20 mm/px tiles | 0.469 and 0.523 |
| ...0.20 was the wrong scale | O13 swept 0.15 to 0.30, population fixed | 8 points, all at chance |
Each row is a pre-registered experiment with a design document committed before its data existed. The question is closed. A sixth variant of the same method is not warranted without new evidence, and the imagery to test a finer hypothesis does not exist in this collection: nine works in the entire corpus have imagery finer than 0.05 mm/px, and zero support a full-range sweep for Signal A.
5. Reasoning behind the crucial decisions
The full log is docs/decisions.md, D01 through D38, each dated. Six decisions changed
what the project could conclude.
D19 to D21, D32: splits come from catalogue text, not from judgement
Split assignment is a priority rule over description and creator fields in acquire.py.
The alternative, deciding case by case which works "look like" workshop pieces, would
have made the labels a function of the same visual intuition the model was supposed to
test. Machine-readable provenance is a weaker label but an independent one.
The cost is visible: three pupil works were lost because a Phase 1 probe had already claimed them, and the rule forbids re-splitting. Keeping the rule and reporting the loss is the whole point of having the rule.
D30: leave-one-out cohort fits, enforced by control flow
With a cohort of 23, letting a work contribute to the normal it is scored against would pull its own z-score toward zero and make the cohort look artificially tight. LOO is standard; what is less standard is enforcing it structurally. Every fit path branches on the split, so a new split is non-fitting by construction. A convention someone has to remember is a leak waiting to happen.
D30, again: no single opaque score
combined = z_A + z_B with both components and the named top drivers kept on every row.
A learned combination would have been trivially better on paper and would have needed a
training set, which does not exist here at this N, and would have destroyed the ability
to say why a work was flagged. For a triage tool whose users are curators, the
explanation is the product. In hindsight this decision is what let the failure be
diagnosed at all: because the signals were separable, O06 could report z_A = 0.427 and
z_B = 0.522 independently, which is what pointed at the embedding branch first.
D33: capture geometry, change no score
dimensions.py records physical dimensions and derived mm/px, and features.py,
embed.py and score.py do not read those columns. Resampling to a fixed physical
resolution was a separate, still-unmade decision at that point, and the audit report
deliberately declined to pick a floor. Mixing data capture with a scoring change would
have made it impossible to attribute any subsequent difference to either one.
This is the decision that produced the diagnosis. It changed no number and it explained every number.
D34: fetch fixed physical area, and report the works you cannot answer
The obvious fix for incommensurable pixels is to resize everything to a common mm/px. That either upsamples (inventing detail) or downsamples the good images to match the bad ones. Requesting IIIF regions of fixed physical size avoids both, at the cost of a hard eligibility floor.
The floor is derived at query time and never stored on the works table. The table holds
measured facts; the floor is policy; cached policy goes stale silently and nobody notices.
That separation is why python tiles.py --plan can answer the eligibility question for a
different floor without a migration.
D37: fix the population before sweeping, and correct for multiplicity in advance
Both discussed in §4.7. The short version: a sweep with a per-floor population answers a different and less interesting question, and eight uncorrected tests will hand you a significant result about a third of the time whether or not one exists.
6. Evaluation of the model training process
6.1 There was no training, and that was a decision
No weights were updated at any point. Signal A uses ImageNet-pretrained ResNet50 as a frozen feature extractor; Signal B is closed-form image statistics. The "fitting" in this project is limited to computing a mean, a standard deviation and a centroid over 23 cohort works, leave one out.
That was the right call at this scale and it is worth being explicit about why. Finetuning on 23 positives with 67 held-out negatives would overfit long before it generalised, and any held-out score would then be a statement about the split rather than about Rembrandt. There is no honest train/validation/test partition to be had from 108 works, and a project whose entire credibility rests on pre-registration cannot afford a learned model whose behaviour depends on choices made after seeing data.
The consequence has to be stated with equal clarity: ImageNet features are not brushwork features. ResNet50 was trained to separate object categories, and the distinction between a Rembrandt and a Bol is not an object-category distinction. A flat Signal A curve is evidence against this embedding, not against learned representations in general.
6.2 What was actually validated, and how
| Concern | How it was handled |
|---|---|
| Self-scoring / leakage | LOO centroid and LOO mean/std for cohort rows; enforced by control flow, not convention |
| Label leakage into fits | Only split=cohort fits; pupil and ambiguous never enter a normal |
| Threshold tuning after the fact | Every threshold, seed and k transcribed from a design document committed before acquisition |
| Multiple comparisons | Bonferroni correction locked in advance for the 8 sweep tests; both intervals printed at every point |
| Baseline gaming | Every ΔAUC re-fits the baseline from scratch on the same population, so only one thing differs between arms |
| Content-based filtering | A tile is never dropped for what it depicts; undefined feature cells are excluded per-feature |
| Silent failures | Each stage writes results/qc_<recipe_id>/ with a failures CSV and a summary JSON |
| Output drift | Frozen recipe IDs; stages refuse to overwrite without --force; verification is re-run and diff the manifest |
A Phase 4 leakage and scope review (results/phase4_review.md) passed independently of
the authors of the scoring code.
6.3 Where this evaluation is weak
Power. N is 40 and 35 in the sweep, 55 and 52 in O09 and O11. These are well powered for a large effect and badly powered for a small one. Failing to find an effect is not the same as showing there is none, and the confidence intervals in §4.7 are wide enough to admit a real AUC of 0.65 at some floors. The claim defended here is narrow: no effect large enough to be useful for triage was detectable under these conditions.
One museum, one artist, one backbone. Nothing here generalises to other collections without re-running it.
Tier 2 works were never pooled, by design, so the sensitivity analysis they were meant to support is thin at sweep scale (one work per sweep).
The negative result depends on the pupil labels being right. They come from museum
creator fields matched against a documented pupil roster, which is better than
guesswork and worse than scholarly consensus.
6.4 What a stronger claim would need
Higher native resolution than this collection publishes, for a start: raking-light or macro imagery at 0.05 mm/px or better, which exists for individual famous works and not for corpora. Beyond that, a representation trained on handling rather than on object categories, and a held-out set an order of magnitude larger with provenance-grade labels. Those are all real research programmes rather than a next sprint, and none of them is reachable by re-tuning what is in this repository.
7. What actually ships
The ranking is not usable and the repository says so in every report, in the demo viewer's own banner, and on the findings page. What is reusable is the adequacy verdict.
python tiles.py --plan
Runs offline in under a second against the committed SQLite database and reports, per work, whether the question can be answered from published imagery at the 0.20 mm/px floor and, when it cannot, which test it failed. 64 of 108 answerable, 44 not.
That verdict is independent of whether the ranking works, applies to any IIIF collection, and answers a question a curator or a digitisation team actually has: is our imagery good enough to support this kind of analysis, and if not, what would we need to re-photograph? For this corpus the honest answer for 44 works is no, and saying so is more useful than returning a confident-looking number computed from pixels that cannot support it.
8. Reproducing this
Everything except two regenerable caches is committed, so a clone reproduces the whole result without network access.
mamba activate CohortScope
python -m pip install -r requirements.txt
python preprocess.py # rebuilds the ~340 MB cache byte-identically
python features.py --force
python score.py --force # O04
python evaluate_pupils.py --force # O06
python resolution_audit.py --force
python tile_score.py --force # O09
python tile_score_a.py --force # O11
python sweep.py --force # O13
python dossier.py # the findings page
Tile stages need python tiles.py / python cnn_tiles.py / python sweep.py --fetch
first if data/tiles/ is absent; those are the only steps that touch the network, along
with acquire.py and dimensions.py. There is no test suite and no CI. Verification is
by re-running a stage with --force and diffing its manifest against the committed one.
| Artifact | Path |
|---|---|
| Findings page | results/dossier/index.html |
| Ranked scores and fit manifest | results/scores/ |
| O04 / O06 / O09 / O11 / O13 reports | results/*_report.md |
| Pre-registrations, committed before their data | results/phase{4,7,8,9,10,11}_*_design.md |
| Decision log, D01 to D38 | docs/decisions.md |
| Physical geometry | data/cohortscope.sqlite, works.mm_per_px_* |
| Per-recipe QC logs | results/qc_*/ |
9. Closing
The ranking does not work and should not be used. What the project produced instead is a measurement and a tool.
The measurement: on this corpus, model-based attribution triage is limited not by architecture and not by sample size but by how much canvas a published pixel covers. That quantity is recorded, auditable, and worse than most people assume. Only 6.3% of the resolution the museum already publishes was reaching the model, and after a standard resize-and-crop not one painting in 108 was being shown a brushstroke.
The tool: a per-work verdict on whether the question is answerable at all, derived from the museum's own IIIF metadata, that does not depend on the ranking being correct.
Five outcomes, five design documents committed before their data, no threshold edited after the fact. A negative result that is this specific about its own cause is a more useful contribution than a positive one nobody could reproduce.
Built With
- cv
- gradio
- python
- pytorch
- resnet50
- rijksmuseum
- scikit-learn
- sqlite
Log in or sign up for Devpost to join the conversation.