grid scan: a crystal is grown out of its seeds, so one weak grid point no longer splits it

A crystal in the corpus was reported as two because the grid point joining its halves
scored 0.498 - two thousandths under the threshold. That is an artefact of putting a
hard edge through a continuous quantity, not a gap in a crystal.

The patch search is now hysteresis. A patch is labelled at a lower grow threshold
(0.35) and kept only if it holds a cell above the seed threshold (0.50), which is the
same thing as growing out of the seeds in one pass of the labeller rather than two. A
cell between the two levels joins a crystal that already exists but can never start
one, so no lowering of the grow threshold can turn a background into a crystal - and
the negatives are safe by construction, since water peaks at 0.146 and ice at 0.490,
both under the seed level.

Both halves of the small-patch rule read the GROWN patch - the count includes the
cells growth added and the peak is the patch's best cell wherever it lies - which is
stated at the test, because a reader will otherwise wonder whether a grown cell can
rescue a patch no seed would have admitted. It cannot: the patch is discarded before
that test unless it holds a seed.

The reported extents are those of the grown patch, so they reach the 0.35 contour. Over
the corpus that is major_um x1.04 and n_images x1.07 at the median (x1.11 and x1.14 at
the mean, +7% cells overall), and the cells it adds are spread fairly evenly over
0.35-0.50 rather than piled at the bottom - a crystal edge sampled at one grid step, not
bleed into a neighbour that never diffracted. The patch mean falls with them, and can now
sit below the seed threshold; the peak beside it is what the admission was decided on.
RASTER_REPORT_VERSION is 3, because N_CELLS and the extents are a different measurement
under the same names.

The corpus table is unchanged: 17/17 protein, 0/4 water, 0/3 ice, 10/10 heldout. The
split heals into one crystal, and one raster's five patches become three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-08 07:34:36 +02:00
co-authored by Claude Opus 5
parent cdb60c2916
commit 273591af11
7 changed files with 90 additions and 10 deletions
@@ -40,14 +40,19 @@ GridScanResult AnalyzeGridScan(const ScanResult &scan,
image_no[i] = elem.number;
}
std::vector<uint8_t> above(nx * ny);
// Hysteresis. The patches are labelled at the GROW level and then only those holding at least
// one cell at the SEED level are kept, which is the same thing as growing out of the seeds and
// is one pass of the labeller rather than two. A cell between the two levels therefore joins a
// crystal that already exists but can never start one, so however low the grow level is set, a
// background that never reaches the seed level produces nothing.
std::vector<uint8_t> grown(nx * ny);
for (int64_t i = 0; i < nx * ny; i++)
above[i] = (protein[i] > params.protein_score_threshold) ? 1 : 0;
grown[i] = (protein[i] > params.grow_score_threshold) ? 1 : 0;
// Labelled with no size cut of its own: whether a patch is big enough is no longer a plain
// floor - a small patch survives on the strength of its diffraction - and that test needs the
// patch's scores, which the labeller does not have.
const std::vector<int32_t> label = LabelConnectedComponents(above, nx, ny, 1);
const std::vector<int32_t> label = LabelConnectedComponents(grown, nx, ny, 1);
const int32_t n_label = label.empty() ? 0 : *std::max_element(label.begin(), label.end());
GridScanResult result;
@@ -63,6 +68,14 @@ GridScanResult AnalyzeGridScan(const ScanResult &scan,
if (cell.empty())
continue;
// Hysteresis, the second half: a patch that never reaches the seed level is not a patch,
// whatever its shape, so nothing about it is worth measuring.
bool has_seed = false;
for (int64_t i: cell)
has_seed = has_seed || protein[i] > params.protein_score_threshold;
if (!has_seed)
continue;
const auto n = static_cast<float>(cell.size());
// Cell centres in MICROMETRES, taken once. Every geometric quantity below is computed from
@@ -157,6 +170,12 @@ GridScanResult AnalyzeGridScan(const ScanResult &scan,
// A patch is a crystal when it is big enough to be a shape rather than a coincidence, OR
// when it is smaller than that but the diffraction in it is decisive on its own. One cell
// is enough where that cell is clearly protein; a weak patch still has to be a shape.
//
// Both halves are read over the GROWN patch: the count includes the cells hysteresis added
// and the peak is the patch's best cell wherever it lies. That cannot let growth rescue a
// patch no seed would have admitted, because the patch has already been discarded above
// unless it holds a seed cell - and a seed cell is by definition the strongest kind there
// is, so the peak of a grown patch is the peak of its seeds.
if (static_cast<int64_t>(cell.size()) < params.min_blob_cells
&& peak_protein < params.decisive_protein_score)
continue;