2D microstructure analysisElectrode cross-sections

Electrode microstructure analysis

31 known samples across three batches and 9 test samples. BSE, Inlens and ETD/SE measurements, methods and comparisons.

Abstract

We assess electrode-batch similarity using 13 physical descriptors and a porosity uncertainty estimate from 31 labelled crops and 9 test samples. Joint segmentation of BSE, Inlens and ETD/SE images measures phase area fractions, pore structure, and silicon-region geometry and spatial variation. Test vectors are assigned to the nearest standardised batch mean. Leave-one-crop-out validation correctly assigns 18 of 31 crops (58.1%). Confidence bands report validation precision for each predicted batch. The report provides individual measurements, segmentation figures and reproducible calculations. Segmentation boundaries and crop independence remain unvalidated; manufacturing acceptance criteria have not been established.

1. Introduction

Batch 3 represents the material promised by the supplier. Batches 1 and 2 contain subsequent, deliberately assembled patterns of variation; neither is labelled better or worse. The challenge evaluates whether each held-out image can be assigned to Batch 1, 2 or 3 with confidence and a physical explanation. Distinguishing Batch 1 from Batch 2 matters as much as distinguishing either from the reference.

We compare four feature families: phase area fractions, pore geometry, silicon particle size and shape, and silicon particle spatial variation. The organisers describe the data as crops from approximately 15 original electrode images, arranged into artificial batches. This is a test of recognising microstructural patterns. Establishing manufacturing acceptance limits or performance on genuinely new material populations would require separate validation.

These measurements describe how much of each assigned phase is present, the dimensions and directionality of pore space, and the geometry and distribution of silicon particles. Each value can be traced to a mask or geometric calculation. Their physical interpretation is limited by the two-dimensional images and the phase assignments; they do not directly measure transport or establish a manufacturing defect.

The same three-detector extraction is applied to known and test samples. Each assignment is accompanied by its measured physical features.

2. Physical interpretation and glossary

What the electrode characteristics mean, why we measured them, and how each measurement was obtained.

Overall rationale

A single number such as porosity cannot fully describe an electrode, because microstructure can change in ways one measure misses. The current report covers phase composition, pore organisation, silicon particle size and shape, and silicon particle spatial variation. These complementary families target different aspects of the microstructure (our interpretation).

  • 2D estimates of 3D properties can be biased and orientation-dependent. Tortuosity and 3D connectivity cannot be recovered reliably from these sections. Compare batches in the same orientation (our interpretation). Taiwo et al.
  • Representativity is property-specific: an image representative for phase fraction need not be representative for interface. Dahari et al.
  • Uncertainty estimates assume perfect segmentation; segmentation error can exceed finite-image sampling error. Dahari et al.

(a) Phase area fractions

A phase is a distinct material or pore region, such as graphite or void space. Its area fraction is the percentage of the image assigned to it.

Pore, graphite, carbon-binder and silicon particle area fractions.

Physical meaning

An electrode contains solid material and void space. In a conventional liquid-electrolyte cell, electrolyte in the pores carries ions; active material stores lithium; conductive carbon provides electronic connections. Area fractions describe how much of each assigned region is visible in a cross-section. The project lead identifies the silicon particles as silicon active material; their area fraction is not silicon weight fraction.

Phase
A distinguishable constituent or region. Phase names use the supplied material identity; the intensity-derived mask boundaries remain unvalidated.
Area fraction
The share of the analysed two-dimensional image assigned to a region. It is not a mass fraction or a directly measured three-dimensional volume fraction.
Porosity
The area fraction assigned to pore space in this report.
Carbon-binder domain (CBD)
Conductive carbon and polymer binder associated with particle contacts. Our CBD value is an intensity-based allocation within the pore-labelled cluster.
Relevance to electrode performance

The balance of active material, pore space and conductive material affects how much charge an electrode can store and how ions and electrons reach reaction sites. In a liquid-electrolyte electrode, pore space accommodates the ionic conductor, while solid active material supplies storage sites. More porosity is therefore not automatically better: transport and active-material loading must both be considered.

Literature basis

Silicon expansion and electrochemical–mechanical interactions affect degradation in silicon–graphite composites, motivating separate tracking of silicon content. Moon et al. (2021).

In reconstructed LiCoO₂ cathodes, pore space, active material and carbon-binder regions serve distinct ionic transport, lithium-storage and electronic transport roles. Including carbon-binder nanoporosity changed the calculated ionic conduction. Zielke et al. (2015).

Experiments with graphite particle-size mixtures linked changes in packing and porosity with changes in electrochemical behaviour. Choi et al. (2023).

Scope for this analysis: These studies motivate measuring phase proportions. They do not validate our image-intensity labels, CBD allocation or a batch acceptance threshold.

How we defined and measured each phase

Retain the central 80% of image rows and smooth each detector with Gaussian σ = 1 pixel. Standardize BSE, Inlens and ETD/SE separately within each crop, then cluster their joint pixel vectors with three-class KMeans. Order the fitted centroids by their BSE coordinate to label pore, graphite and silicon.

Current three-channel phase assignment. Each pixel is assigned to its nearest fitted centroid in standardized BSE/Inlens/ETD or SE intensity space.
Reported regionPixel assignment
Pore-labelled areaJoint cluster whose fitted centroid has the lowest BSE coordinate.
Graphite-labelled matrixJoint cluster whose fitted centroid has the intermediate BSE coordinate.
Silicon particlesJoint cluster whose fitted centroid has the highest BSE coordinate. Silicon identity is supplied by the project lead; mask boundaries remain provisional.
Carbon-binder allocationWithin the pore-labelled cluster, smoothed Inlens intensity above its within-cluster median. Pixels at or below that median form the open-pore subset.

For every reported region, divide its pixel count by the full cropped image area and multiply by 100. Porosity uses the entire pore mask from the selected segmentation, including its CBD allocation. The Inlens median split is retained in both methods. Combined segmentation and its assumptions.

fk=100 NkNcrop  [%]f_k = 100\,\frac{N_k}{N_{\mathrm{crop}}}\;[\%]

Pore, graphite and silicon particle fractions sum to 100%. CBD is a subset of the pore-labelled area, so adding it again would double count. The median split has not been independently calibrated as a binder measurement.

View calculation code

(b) Pore structure

The scale and directional organisation of the empty regions visible between solid material.

Correlation length scale, horizontal and vertical pore chords, and chord anisotropy.

Physical meaning

The pore network provides space for electrolyte. Its geometry affects how ions can move through an electrode. We describe the patterns visible in a cross-section, including whether pore runs are longer horizontally or vertically.

Chord length
The length of an uninterrupted straight segment through a pore in the image. It describes a line through the void, not a pore diameter or a three-dimensional throat.
Anisotropy
A difference with direction. Here it is the ratio of mean horizontal to mean vertical pore chord length.
Correlation length
A spatial scale derived from how pore labels co-vary at separated positions. It is distinct from the mean chord length.
Tortuosity
The transport penalty associated with the geometry of connected paths. It is not measured by this two-dimensional analysis.
Relevance to electrode performance

Ion transport depends on how pore space is arranged and connected, not only on its amount. Direction-dependent pathways can produce different transport resistance through the electrode thickness and along its plane, affecting rate capability. Our chords describe visible directional geometry; they do not measure transport resistance or tortuosity. The correlation scale characterises spatial organisation and representativity, rather than providing a direct performance score.

Literature basis

An analytical composite-electrode model identifies initial porosity and expansion tolerance as design factors. It does not validate our pore chords as a measure of silicon expansion. Otero et al. (2018).

Tomography and diffusion simulations linked particle shape and alignment to direction-dependent tortuosity, showing why transport cannot be described by porosity alone. Ebner et al. (2014).

Graphite-anode tomography found different tortuosity and surface area in aged and pristine material despite similar porosity. Mitsch et al. (2014).

The two-point correlation function was used to estimate phase-fraction representativity. This supports our correlation length and porosity interval, rather than a direct performance prediction. Dahari et al. (2025).

Scope for this analysis: The transport studies motivate measuring pore geometry. They do not establish that our two-dimensional chord lengths or chord ratio measure tortuosity or predict rate capability.

How we calculated it

On the full pore-labelled cluster, including its CBD allocation, measure uninterrupted runs along every 40th row and column. Multiply pixel lengths by 0.025 µm/pixel and take the mean in each direction. Divide the horizontal mean by the vertical mean. ImageRep separately calculates the correlation length scale from the same mask.

Lx=s ℓx‾,Ly=s ℓy‾,R=Lx/LyL_x=s\,\overline{\ell_x},\qquad L_y=s\,\overline{\ell_y},\qquad R=L_x/L_y

A longer chord does not establish better transport. These measurements do not recover three-dimensional connectivity or tortuosity. Interpreting image x and y as electrode directions requires known specimen orientation.

View calculation code

(c) Silicon particle size and shape

The dimensions and elongation of connected silicon particle regions in the image.

Silicon particle aspect ratio and equivalent-diameter percentiles D10, D50 and D90.

Physical meaning

A silicon particle region is a connected component of the silicon-labelled cluster. Size and shape measurements describe this population so that batches can be compared even when their total silicon area is similar. Silicon identity follows the project lead’s identification; the cluster boundaries remain provisional.

Equivalent diameter
The diameter of a circle with the same area as a segmented region.
D10, D50 and D90
The diameters below which 10%, 50% and 90% of the measured objects fall. Every retained object contributes one observation.
Aspect ratio
The major-axis length divided by the minor-axis length of an ellipse fitted to an object's shape. Larger values indicate more elongated regions.
Relevance to electrode performance

The size and shape of identified electrode particles can affect packing and pore geometry, with consequences for transport. Silicon particle size has been linked to degradation in silicon–graphite anodes. Our size and shape measurements describe the segmented regions, not their cycling performance.

Literature basis

Silicon particle size and graphite hardness affected degradation in silicon–graphite anodes; this supports distinguishing silicon-region size from graphite-particle size. Moon et al. (2021).

Changing the size distribution of identified graphite particles altered packing, porosity and electrochemical behaviour in fabricated anodes. Choi et al. (2023).

Particle shape and fabrication-induced alignment were linked to directional tortuosity using tomography and diffusion simulations. Ebner et al. (2014).

Scope for this analysis: Silicon size may be relevant to degradation, but our 2D regions may contain merged particles. Silicon aspect ratio remains exploratory; no performance or damage threshold is established.

How we calculated it

Label connected components in the silicon mask and retain components of at least 10 pixels. Convert their areas to equivalent-circle diameters and calculate unweighted diameter percentiles. Average major-axis/minor-axis ratios, with a one-pixel minimum denominator and zero minor axes excluded.

deq=s4Aπ,Ar‾=mean⁡ ⁣(amax⁡(b,1))d_{\mathrm{eq}}=s\sqrt{\frac{4A}{\pi}},\qquad \overline{A_r}=\operatorname{mean}\!\left(\frac{a}{\max(b,1)}\right)

A is component area in pixels; a and b are ellipse axes in pixels. These objects are not separated by watershed, so touching regions may form one component. No defect threshold is inferred from their geometry.

The geometric definitions follow the region measurement definitions in scikit-image.

View calculation code

(d) Silicon particle spatial variation

How unevenly silicon particles are distributed across different parts of an image.

Standard deviation of local silicon particle area fractions across 16 image regions.

Physical meaning

Two cross-sections can contain the same overall silicon particle fraction but distribute it differently: one relatively evenly, the other in concentrated regions. A spatial measurement captures this difference in local silicon coverage. It does not measure binder distribution or identify why clustering occurred.

Local area fraction
The percentage of one image region occupied by the silicon particle mask.
Spatial variation
Differences in local area fraction between regions. This describes uneven coverage, rather than uncertainty in the overall mean.
Percentage points
The units of differences between percentages. Variation from 5% to 8% is a difference of 3 percentage points.
Relevance to electrode performance

Spatially uneven electrode microstructure can create local differences in transport and reaction conditions, so equal average composition does not guarantee equal electrochemical behaviour. Our statistic asks whether silicon coverage is uniform across an image. It is a screening measurement of heterogeneity, not a validated predictor of current distribution, capacity or a mixing defect.

Literature basis

Wet ball milling conditions affected silicon agglomeration and distribution. This motivates examining silicon coverage, but does not validate our 4 × 4 statistic as a processing diagnosis. Cabello et al. (2020).

Tomography and electrochemical simulations of commercial graphite anodes showed uneven current distributions and higher local overpotentials in heterogeneous structures compared with a more uniform comparison. Müller et al. (2018).

Scope for this analysis: This supports examining spatial heterogeneity. It does not validate our 4 × 4 silicon-particle statistic as a predictor of current distribution, capacity, mixing defects or failure.

How we calculated it

Divide the silicon particle mask into 4 × 4 tiles. Compute the silicon particle area percentage in each tile, then take the population standard deviation of the 16 percentages (ddof = 0).

σinc=116∑j=116(fj−f‾)2\sigma_{\mathrm{inc}}=\sqrt{\frac{1}{16}\sum_{j=1}^{16}(f_j-\overline f)^2}

This value depends on the tile size, image crop and segmentation. Higher variation indicates less uniform local coverage at that scale; it does not identify the manufacturing cause.

View calculation code

These four families contain 13 physical descriptors. Porosity uncertainty is reported alongside them as information about measurement precision. Taiwo et al. (2016) explain why two-dimensional sections cannot resolve all three-dimensional geometry and connectivity.

3. Methods

The current pipeline jointly segments BSE, Inlens and ETD/SE, then extracts one 13-dimensional physical descriptor vector per sample. Porosity uncertainty is reported separately. Calculation links open the combined extraction code below.

From image to vector

Three-channel KMeans assigns each pixel to a pore, graphite or silicon-labelled region. Physical measurements are calculated from these masks.

  1. 1. Prepare

    Match BSE, Inlens and ETD/SE by sample. Retain the first stored intensity channel, crop the central 80% of rows and smooth each detector with Gaussian σ = 1 pixel. Use 0.025 µm/pixel.

  2. 2. Segment

    Standardize each detector within the crop. Fit three-class KMeans to sampled joint pixel vectors, initialized from BSE Multi-Otsu. Order fitted centroids by their BSE coordinate, then assign every cropped pixel.

  3. 3. Measure

    Count phase pixels and retain the Inlens median CBD allocation. Apply ImageRep and chord measurements to the full pore mask; measure silicon components and variation across 16 tiles.

  4. 4. Export

    Save 13 physical descriptors and porosity uncertainty for each sample and segmentation. Retain full precision for comparisons; round only for display.

Image preparation and three-detector segmentation

Inputs and image preparation

Detector images and crop

Match BSE, Inlens and ETD images by sample identifier. Four known samples use the filename label SE for the third detector; this naming convention does not establish identical acquisition settings. Require equal pixel grids and inspect detector correspondence before stacking. No automatic registration or resampling is applied.

Select the first stored intensity channel, retain rows int(0.1*h):int(0.9*h) and every column, then smooth each detector with Gaussian sigma=1.0, preserve_range=True. The pixel-size setting is 0.025 µm/pixel; it has not been independently verified from the TIFF metadata. Image preparation code.

Joint segmentation

  1. Standardize each smoothed detector within its full crop by subtracting its mean and dividing by its population standard deviation (ddof=0). Each pixel becomes a three-component vector of standardized BSE, Inlens and ETD/SE intensities.
  2. Select up to 100,000 pixel coordinates without replacement using NumPy seed 42. Three-class Multi-Otsu on the smoothed BSE image provides initial labels. Initialize each joint centroid as the mean sampled vector in its BSE class.
  3. Fit three-cluster K-means using those centroids, n_init=1, random_state=42, max_iter=300, tol=1e-4 and the Lloyd algorithm. Assign every cropped pixel to its nearest fitted centroid.
  4. Order the fitted centroids by their BSE coordinate. Label the clusters pore, graphite matrix and silicon from lowest to highest BSE centroid intensity.

Centroids and channel normalization are fitted separately to each image. The algorithm, initialization rule and settings stay fixed for known and test images. Constant or nonfinite channels, missing initialization classes, collapsed clusters or reaching the iteration limit stop extraction. Segmentation code.

Phase definitions and carbon-binder allocation

The three clusters are mutually exclusive image-based phase assignments. The project lead identifies the bright material as silicon; the intensity rule does not validate every segmented boundary. Every phase fraction uses the full cropped image as its denominator.

Within the pore-labelled mask, calculate the median smoothed Inlens intensity. Pixels above this median form the carbon-binder allocation (CBD); pixels at or below it form the open-pore subset. CBD is approximately half the pore area by construction and is not an independently calibrated binder measurement.

The masks satisfy pore = open_pore + CBD and pore + graphite + silicon = 100%. CBD must not be added again as a fourth independent composition fraction. All pore geometry and ImageRep calculations use the entire pore-labelled mask, including the CBD allocation. Mask measurement code.

Diagnostic figures display matched central 900 × 900 pixel windows; measurements use the full crop. Additional rims and lines can enter the silicon-labelled class. These boundaries require validation before a segmented component can be treated as an individual particle. Inspect the segmentation.

Three-channel segmentation

Each pixel contributes separately standardized BSE, Inlens and ETD/SE intensities to KMeans. The channels are stacked, not averaged, and retain their original coordinates without automatic registration.

The top row shows detector views after Gaussian smoothing (σ = 1 pixel). The bottom-right mask is the three-channel segmentation used for the reported measurements. The other masks show the BSE initialisation and clustering control. Each figure displays the same central 900 × 900 pixel window; percentages use the whole crop, which retains the central 80% of image height. The separate CBD median allocation is not shown.

Batch 1, sample 4ih2ggld: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: Fig. 1. Batch 1, sample 4ih2ggld

Scroll within the image to inspect details.

Batch 1, sample 4ih2ggld: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Fig. 1. Batch 1, sample 4ih2ggld. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
Batch 3, sample 0grcilhi: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: Fig. 2. Batch 3, sample 0grcilhi

Scroll within the image to inspect details.

Batch 3, sample 0grcilhi: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Fig. 2. Batch 3, sample 0grcilhi. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.

Additional known sample

Batch 2, sample rxax5ozo
Batch 2, sample rxax5ozo: smoothed BSE, Inlens and SE views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: Batch 2, sample rxax5ozo

Scroll within the image to inspect details.

Batch 2, sample rxax5ozo: smoothed BSE, Inlens and SE views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Batch 2, sample rxax5ozo. Detector views and segmentation outputs from the combined run. The third detector is SE. Silicon-labelled mask boundaries remain provisional.

Full test set (9 samples)

test_1, sample 0eryguqq
test_1, sample 0eryguqq: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample 0eryguqq

Scroll within the image to inspect details.

test_1, sample 0eryguqq: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample 0eryguqq. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample 3e122cbj
test_1, sample 3e122cbj: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample 3e122cbj

Scroll within the image to inspect details.

test_1, sample 3e122cbj: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample 3e122cbj. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample 4hq27w4c
test_1, sample 4hq27w4c: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample 4hq27w4c

Scroll within the image to inspect details.

test_1, sample 4hq27w4c: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample 4hq27w4c. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample fhwrjtet
test_1, sample fhwrjtet: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample fhwrjtet

Scroll within the image to inspect details.

test_1, sample fhwrjtet: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample fhwrjtet. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample fn0mhxef
test_1, sample fn0mhxef: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample fn0mhxef

Scroll within the image to inspect details.

test_1, sample fn0mhxef: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample fn0mhxef. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample fspqbkxl
test_1, sample fspqbkxl: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample fspqbkxl

Scroll within the image to inspect details.

test_1, sample fspqbkxl: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample fspqbkxl. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample soo2ax3r
test_1, sample soo2ax3r: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample soo2ax3r

Scroll within the image to inspect details.

test_1, sample soo2ax3r: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample soo2ax3r. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample xrv9xvzb
test_1, sample xrv9xvzb: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample xrv9xvzb

Scroll within the image to inspect details.

test_1, sample xrv9xvzb: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample xrv9xvzb. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.
test_1, sample y59rxmxl
test_1, sample y59rxmxl: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
Inspect at full resolution: test_1, sample y59rxmxl

Scroll within the image to inspect details.

test_1, sample y59rxmxl: smoothed BSE, Inlens and ETD views above the BSE Multi-Otsu initialisation, BSE-only clustering control and three-channel result.
test_1, sample y59rxmxl. Detector views and segmentation outputs from the combined run. The third detector is ETD. Silicon-labelled mask boundaries remain provisional.

Physical descriptor vector

xi=[ϕporefgraphitefCBDfsiliconaCLSLyLxLx/LyAsiliconD10D50D90σsilicon],x0grcilhi=[16.9358.268.4724.812.8170.6400.7701.202.950.0980.1740.6076.16]∈R13\mathbf{x}_i = \begin{bmatrix} \phi_{\mathrm{pore}} \\ f_{\mathrm{graphite}} \\ f_{\mathrm{CBD}} \\ f_{\mathrm{silicon}} \\ a_{\mathrm{CLS}} \\ L_y \\ L_x \\ L_x/L_y \\ A_{\mathrm{silicon}} \\ D_{10} \\ D_{50} \\ D_{90} \\ \sigma_{\mathrm{silicon}} \end{bmatrix}, \qquad \mathbf{x}_{\mathrm{0grcilhi}} = \begin{bmatrix} 16.93 \\ 58.26 \\ 8.47 \\ 24.81 \\ 2.817 \\ 0.640 \\ 0.770 \\ 1.20 \\ 2.95 \\ 0.098 \\ 0.174 \\ 0.607 \\ 6.16 \end{bmatrix} \in \mathbb{R}^{13}
Batch 3, sample 0grcilhi, using the combined three-detector segmentation. Descriptors retain their measured units; porosity uncertainty accompanies the vector separately. The saved record has 14 fields.
Physical descriptor definitions (13)
The 13 physical descriptors in vector order. Example: Batch 3, sample 0grcilhi, from the saved measurements. Porosity uncertainty is defined separately below.
Physical descriptorValueCalculation
Pore area fraction%16.93Pore-labelled area / image area × 100. Describes the area assigned to voids.Calculation
Graphite area fraction%58.26Graphite-labelled area / image area × 100. Describes the assigned active-material fraction.Calculation
Carbon-binder allocation%8.47Pore-mask pixels above the median Inlens intensity / analysed area × 100. This heuristic assigns approximately half the pore area to CBD.Calculation
Silicon particle area fraction%24.81Silicon-labelled area / image area × 100. Describes bright regions; their chemistry is not established by this value.Calculation
Characteristic length scaleµm2.817ImageRep integral_range × 0.025 µm/pixel, calculated from the binary pore mask. Describes the spatial correlation scale.Calculation
Vertical pore chord lengthµm0.640Mean uninterrupted pore run along image y, sampling every 40th column at 0.025 µm/pixel. A 2D chord, not a 3D throat width.Calculation
Horizontal pore chord lengthµm0.770Mean uninterrupted pore run along image x, sampling every 40th row at 0.025 µm/pixel.Calculation
Pore anisotropyratio1.20Horizontal / vertical mean chord length. Values above 1 indicate longer horizontal chords.Calculation
Silicon particle aspect ratioratio2.95Mean ellipse major axis / max(minor axis, 1 pixel), excluding zero minor axes. Calculated on silicon particle components of at least 10 pixels.Calculation
Silicon particle diameter D10µm0.09810th percentile of area-equivalent diameter, d = √(4A/π), for silicon particle components of at least 10 pixels. Each component has equal weight.Calculation
Silicon particle diameter D50µm0.174Median area-equivalent diameter of the same silicon particle components. This measures silicon particles, not graphite flakes.Calculation
Silicon particle diameter D90µm0.60790th percentile of the same unweighted silicon-particle-diameter distribution. Describes its larger objects.Calculation
Silicon particle spatial variationpercentage points6.16Population standard deviation of silicon particle area percentages across a 4 × 4 grid. Describes local variation, not count variance / mean.Calculation
Porosity uncertainty

ImageRep estimates an absolute porosity error with confidence 0.95 and target error 0.05. Multiplying its returned absolute error by 100 gives the interval half-width in percentage points, stored as porosity_ci95_pct.

The interval estimates how representative the image is of bulk porosity. Dahari et al. (2025) provide the calculation. Measurement-error models such as Kelly (2007) treat uncertainty separately from observed values. Storing an interval alongside the descriptors does not by itself make a predictive model uncertainty-aware.

A smaller interval indicates a more precise porosity estimate under this model. It does not indicate a better material. The estimate is conditional on the pore mask and does not include segmentation or phase-assignment error.

Porosity uncertainty calculation

Feature calculations and assumptions

Correlation and porosity uncertainty

Call ImageRep as make_error_prediction(pore_mask.astype(int), confidence=0.95, target_error=0.05). Its spatial-correlation model returns integral_range and abs_err; export these as integral_range × s and abs_err × 100. The implementation uses periodic FFT two-point correlation and includes its model-error correction. This is a correlation-based calculation.

target_error=0.05 is a relative error target, not a requirement that every interval equal ±5%. The interval estimates image representativity conditional on the segmentation and excludes phase-assignment error. A failed or nonfinite calculation stops extraction. ImageRep call; Dahari et al..

Pore chords and silicon-region geometry

Pad each sampled binary scan with zeros and pair positive and negative transitions; end − start gives the pore run length in pixels. Runs truncated by the crop boundary are included. Empty chord lists return zero, and anisotropy returns one if the vertical mean is zero. Horizontal and vertical refer to image axes; interpreting them as electrode directions requires known specimen orientation. These are two-dimensional chords, not three-dimensional pore throats or tortuosity. Chord calculation.

Label silicon-mask components using skimage.measure.label and measure them with regionprops. Retain components of at least 10 pixels. Diameter percentiles give equal weight to each retained component; no watershed separation or volume weighting is applied. Aspect ratios exclude zero minor axes and use a one-pixel minimum denominator. Empty diameter lists return zero and an empty aspect-ratio list returns one. Review these fallback values when interpreting a sample. Component calculation.

For spatial variation, np.array_split divides the silicon mask into 4 × 4 tiles. Calculate silicon coverage as a percentage within each tile, then use np.std(tile_percentages, ddof=0). This measures local area-fraction variation in percentage points, not a validated mixing or processing defect. Spatial calculation.

From measurements to batch comparisons

Compare each test vector with known vectors from the same extraction. A standardised nearest-batch-mean rule assigns each sample; confidence bands use leave-one-crop-out validation precision.

Feature extraction code (Python)

The executed script for detector preparation, joint segmentation and physical measurements. The reported vectors use its stacked_three_channel output.

#!/usr/bin/env python3
"""Separate three-detector segmentation experiment; original exports are read-only."""
from __future__ import annotations
 
import argparse
import csv
import hashlib
import json
from pathlib import Path
import platform
import shutil
import subprocess
import sys
 
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch
import numpy as np
from PIL import Image
import sklearn
import scipy
import skimage
import PIL
from sklearn.cluster import KMeans
from skimage.filters import gaussian, threshold_multiotsu
from skimage.measure import label, regionprops
from threadpoolctl import threadpool_limits
 
PORTAL_ROOT = Path(__file__).resolve().parents[1]
ROOT = PORTAL_ROOT.parent
sys.path.insert(0, str(PORTAL_ROOT / "scripts"))
from extract_physical_features import FEATURE_COLUMNS, load_imagerep
from compare_physical_batches import FEATURES
 
PIXEL_SIZE = .025
VARIANTS = ["original_bse_otsu", "bse_kmeans_control", "stacked_three_channel"]
PHASES = ["Pore-labelled", "Graphite-labelled", "Silicon-labelled"]
COLOURS = ["#202020", "#b7bbc0", "#bd8b43"]
 
 
def digest(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()
 
 
def inventory(data_dir, batch_names=None):
    rows = []
    for batch in batch_names or ["Batch_1", "Batch_2", "Batch_3", "test_1"]:
        files = sorted((data_dir / batch).glob("img_*_BSE.tif"))
        if not files:
            raise ValueError(f"No BSE images in requested directory: {data_dir / batch}")
        for bse in files:
            sample = bse.stem.removeprefix("img_").removesuffix("_BSE")
            inlens = bse.with_name(f"img_{sample}_Inlens.tif")
            thirds = [bse.with_name(f"img_{sample}_{name}.tif") for name in ["ETD", "SE"]]
            thirds = [p for p in thirds if p.exists()]
            if not inlens.exists() or len(thirds) != 1:
                raise ValueError(f"Expected BSE/Inlens and exactly one ETD or SE file: {sample}")
            rows.append({"sample_id": sample, "batch": batch, "paths": [bse, inlens, thirds[0]],
                         "third_channel": thirds[0].stem.rsplit("_", 1)[1]})
    if not rows:
        raise ValueError("No samples found")
    return rows
 
 
def load_channels(paths):
    images, shapes = [], []
    for path in paths:
        with Image.open(path) as im:
            raw = np.array(im)
        if raw.ndim == 3:
            raw = raw[:, :, 0]
        if raw.ndim != 2:
            raise ValueError(f"Expected a 2D intensity image: {path}")
        shapes.append(raw.shape)
        h = raw.shape[0]
        images.append(gaussian(raw[int(.1*h):int(.9*h), :], sigma=1.0, preserve_range=True))
    if len(set(shapes)) != 1:
        raise ValueError("Channels have different pixel grids; no automatic resize or registration is performed")
    return images, shapes[0]
 
 
def otsu_mask(bse):
    low, high = threshold_multiotsu(bse, classes=3)
    labels = np.zeros(bse.shape, dtype=np.uint8)
    labels[bse >= low] = 1
    labels[bse >= high] = 2
    return labels, [float(low), float(high)]
 
 
def clustered_mask(channels, original, seed=42, max_pixels=100_000):
    means = np.array([im.mean() for im in channels])
    scales = np.array([im.std(ddof=0) for im in channels])
    if not np.isfinite(scales).all() or np.any(scales <= 0):
        raise ValueError("A detector image is constant or nonfinite")
    flat = [im.ravel() for im in channels]
    sampled_indices = np.random.default_rng(seed).choice(original.size, min(max_pixels, original.size), replace=False)
    sampled = (np.column_stack([im[sampled_indices] for im in flat]) - means) / scales
    sampled_labels = original.ravel()[sampled_indices]
    if set(sampled_labels) != {0, 1, 2}:
        raise ValueError("The sampled pixels do not contain all three initial BSE classes")
    initial = np.array([sampled[sampled_labels == phase].mean(axis=0) for phase in range(3)])
    model = KMeans(n_clusters=3, init=initial, n_init=1, random_state=seed,
                   max_iter=300, tol=1e-4, algorithm="lloyd").fit(sampled)
    if model.n_iter_ >= 300:
        raise ValueError("Clustering reached the iteration limit")
    if len(set(model.labels_)) != 3:
        raise ValueError("Clustering did not retain three distinct classes")
    # Map joint clusters to the same intensity-based interpretation as the original.
    order = np.argsort(model.cluster_centers_[:, 0], kind="stable")
    mapping = np.empty(3, dtype=np.uint8)
    mapping[order] = np.arange(3)
    labels = np.empty(original.size, dtype=np.uint8)
    for start in range(0, labels.size, 250_000):
        end = min(start + 250_000, labels.size)
        pixels = (np.column_stack([im[start:end] for im in flat]) - means) / scales
        labels[start:end] = mapping[model.predict(pixels)]
    return labels.reshape(original.shape), {
        "channel_means": means.tolist(), "channel_population_sd": scales.tolist(),
        "initial_normalised_centres_in_original_phase_order": initial.tolist(),
        "normalised_cluster_centres_in_phase_order": model.cluster_centers_[order].tolist(),
        "raw_cluster_centres_in_phase_order": (model.cluster_centers_[order] * scales + means).tolist(),
        "sampled_pixels": len(sampled_indices), "seed": seed, "iterations": int(model.n_iter_),
        "normalisation": "Per-image mean and population SD; each included channel has equal weight",
        "initialisation": "Means of sampled pixels in the original three BSE Multi-Otsu masks",
        "phase_mapping": "Increasing BSE cluster-centre intensity: pore, graphite, silicon",
    }
 
 
def measure_masks(labels, inlens, core):
    """Original downstream calculations, with the segmentation masks as inputs."""
    pore, matrix, silicon = labels == 0, labels == 1, labels == 2
    if not 0 < pore.mean() < 1:
        raise ValueError("Pore mask cannot be empty or fill the image")
    median = np.median(inlens[pore])
    cbd = pore & (inlens > median)
    uncertainty = core.make_error_prediction(pore.astype(int), confidence=.95, target_error=.05)
    chords = []
    for axis in ["y", "x"]:
        lines = (pore[:, col] for col in range(0, pore.shape[1], 40)) if axis == "y" else (pore[row, :] for row in range(0, pore.shape[0], 40))
        lengths = []
        for line in lines:
            diffs = np.diff(np.concatenate(([0], line.astype(int), [0])))
            starts, ends = np.where(diffs == 1)[0], np.where(diffs == -1)[0]
            lengths.extend((ends - starts) * PIXEL_SIZE)
        chords.append(float(np.mean(lengths)) if lengths else 0.0)
    ly, lx = chords
    props = [p for p in regionprops(label(silicon)) if p.area >= 10]
    diameters = [p.equivalent_diameter_area * PIXEL_SIZE for p in props]
    ratios = [p.axis_major_length / max(p.axis_minor_length, 1) for p in props if p.axis_minor_length > 0]
    d10, d50, d90 = np.percentile(diameters, [10, 50, 90]) if diameters else [0., 0., 0.]
    tiles = [100*np.mean(tile) for band in np.array_split(silicon, 4, axis=0) for tile in np.array_split(band, 4, axis=1)]
    values = [100*float(pore.mean()), 100*float(uncertainty["abs_err"]),
              100*float(matrix.mean()), 100*float(cbd.mean()), 100*float(silicon.mean()),
              PIXEL_SIZE*float(uncertainty["integral_range"]), ly, lx, lx/ly if ly > 0 else 1.,
              float(np.mean(ratios)) if ratios else 1., float(d10), float(d50), float(d90), float(np.std(tiles, ddof=0))]
    if not np.isfinite(values).all():
        raise ValueError("A physical calculation returned a nonfinite value")
    return dict(zip(FEATURE_COLUMNS, values))
 
 
def saved_features():
    result = {}
    aliases = {f["key"]: f["raw_key"] for f in FEATURES}
    aliases["porosity_ci95_pct"] = "ci95_abs_pct"
    for filename, canonical in [("qc_dataset_features.csv", False), ("test_1_physical_features.csv", True)]:
        with (PORTAL_ROOT / "public" / filename).open(newline="") as handle:
            for row in csv.DictReader(handle):
                result[row["sample_id"]] = {key: float(row[key if canonical else aliases[key]]) for key in FEATURE_COLUMNS}
    return result
 
 
def mask_comparison(original, candidate):
    output = {"changed_pixel_fraction": float(np.mean(original != candidate))}
    for k, name in enumerate(["pore", "graphite", "silicon"]):
        a, b = original == k, candidate == k
        output[name + "_iou"] = float(np.sum(a & b) / np.sum(a | b))
        output[name + "_dice"] = float(2 * np.sum(a & b) / (np.sum(a) + np.sum(b)))
        output[name + "_changed_pixel_fraction"] = float(np.mean(a != b))
    return output
 
 
def draw_masks(row, channels, masks, measurements, output):
    # Display a fixed centre window; segmentation and measurements use the full crop.
    h, w = channels[0].shape
    size = min(900, h, w)
    y, x = (h-size)//2, (w-size)//2
    view = (slice(y, y+size), slice(x, x+size))
    fig, axes = plt.subplots(2, 3, figsize=(12, 9.8))
    for ax, im, name in zip(axes[0], channels, ["BSE", "Inlens", row["third_channel"]]):
        ax.imshow(im[view], cmap="gray", vmin=float(im.min()), vmax=float(im.max()))
        ax.set_title(name, fontweight="bold")
    for ax, mask, variant, title in zip(axes[1], masks, VARIANTS,
                                      ["Original BSE Multi-Otsu", "BSE-only clustering control", "Three-channel clustering"]):
        value = measurements[variant]
        ax.imshow(mask[view], cmap=ListedColormap(COLOURS), vmin=0, vmax=2, interpolation="nearest")
        ax.set_title(f"{title}\nPore {value['porosity_pct']:.2f}%; silicon {value['inclusion_pct']:.2f}%", fontsize=11)
    for ax in axes.flat:
        ax.set_axis_off()
    fig.suptitle(f"{row['batch']} / {row['sample_id']}: matched detector views and masks", fontsize=14, fontweight="bold")
    fig.legend(handles=[Patch(facecolor=c, label=p) for c, p in zip(COLOURS, PHASES)],
               loc="lower center", bbox_to_anchor=(.5, .055), ncol=3, frameon=False)
    fig.text(.5, .022, f"Same central {size} × {size} pixel display window. Percentages use the entire {h} × {w} pixel crop.\nIntensity clusters are candidate phase masks; CBD median allocation is not shown.", ha="center", fontsize=9)
    fig.subplots_adjust(left=.025, right=.975, top=.94, bottom=.13, wspace=.04, hspace=.12)
    fig.savefig(output, dpi=150, facecolor="white")
    plt.close(fig)
 
 
def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--data-dir", type=Path, default=ROOT / "data")
    parser.add_argument("--run-dir", type=Path, required=True)
    parser.add_argument("--imagerep-path", type=Path, default=ROOT / "ImageRep")
    parser.add_argument("--batch-name", action="append", help="Folder under --data-dir; repeat to select several folders")
    parser.add_argument("--sample-id", action="append", default=[])
    args = parser.parse_args()
    if args.run_dir.exists():
        parser.error("Run directory already exists; choose a new directory")
    rows = inventory(args.data_dir, args.batch_name)
    if args.sample_id:
        rows = [r for r in rows if r["sample_id"] in args.sample_id]
        if set(args.sample_id) != {r["sample_id"] for r in rows}:
            parser.error("Requested sample not found")
    args.run_dir.mkdir(parents=True)
    (args.run_dir / "figures").mkdir()
    (args.run_dir / "masks").mkdir()
    original_values = saved_features()
    core = load_imagerep(args.imagerep_path)
    source_files = [Path(__file__), PORTAL_ROOT / "scripts/extract_physical_features.py",
                    PORTAL_ROOT / "scripts/compare_physical_batches.py", Path(core.__file__)]
    source_dir = args.run_dir / "source_snapshot"
    source_dir.mkdir()
    source_hashes = {str(p): digest(p) for p in source_files}
    for path in source_files:
        shutil.copy2(path, source_dir / path.name)
    imagerep_revision = subprocess.check_output(["git", "-C", str(args.imagerep_path), "rev-parse", "HEAD"], text=True).strip()
    records, features = [], []
    for index, row in enumerate(rows):
        print(f"[{index+1}/{len(rows)}] {row['batch']}/{row['sample_id']} ({row['third_channel']})", flush=True)
        channels, native_shape = load_channels(row["paths"])
        original, thresholds = otsu_mask(channels[0])
        # Validate the unchanged downstream calculation against saved full precision.
        baseline = measure_masks(original, channels[1], core)
        saved = original_values.get(row["sample_id"])
        errors = {key: abs(baseline[key] - saved[key]) for key in FEATURE_COLUMNS} if saved else None
        if errors is not None and max(errors.values()) > 1e-10:
            raise ValueError(f"Original-method reproduction failed for {row['sample_id']}: {errors}")
        with threadpool_limits(limits=1):
            bse, bse_fit = clustered_mask(channels[:1], original)
            stacked, stack_fit = clustered_mask(channels, original)
        masks = [original, bse, stacked]
        measured = {VARIANTS[0]: baseline, VARIANTS[1]: measure_masks(bse, channels[1], core),
                    VARIANTS[2]: measure_masks(stacked, channels[1], core)}
        for variant, mask in zip(VARIANTS, masks):
            features.append({"batch": row["batch"], "sample_id": row["sample_id"],
                             "third_channel": row["third_channel"], "variant": variant, **measured[variant]})
            Image.fromarray(mask).save(args.run_dir / "masks" / f"{row['sample_id']}_{variant}.png")
        draw_masks(row, channels, masks, measured, args.run_dir / "figures" / f"{row['sample_id']}.png")
        records.append({"batch": row["batch"], "sample_id": row["sample_id"], "third_channel": row["third_channel"],
                        "input_files": [{"path": str(p), "sha256": digest(p)} for p in row["paths"]],
                        "native_shape": list(native_shape), "crop_shape": list(original.shape),
                        "original_thresholds": thresholds, "original_max_absolute_feature_error": max(errors.values()) if errors is not None else None,
                        "original_verification": "Compared with saved values" if saved else "New sample; no previous export to compare",
                        "fits": {VARIANTS[1]: bse_fit, VARIANTS[2]: stack_fit}, "measurements": measured,
                        "agreement_with_original": {VARIANTS[1]: mask_comparison(original, bse), VARIANTS[2]: mask_comparison(original, stacked)},
                        "added_channel_effect": mask_comparison(bse, stacked)})
        (args.run_dir / "progress.json").write_text(json.dumps(records, allow_nan=False) + "\n")
        print("  pore fractions: " + ", ".join(f"{v}={measured[v]['porosity_pct']:.2f}%" for v in VARIANTS), flush=True)
    with (args.run_dir / "features.csv").open("w", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=["batch", "sample_id", "third_channel", "variant", *FEATURE_COLUMNS])
        writer.writeheader()
        writer.writerows(features)
    report = {"experiment": "Three-channel intensity segmentation v1", "status": "Exploratory side experiment; original results unchanged",
              "method": {"pixel_size_um": PIXEL_SIZE, "crop": "central 80% rows, all columns", "gaussian_sigma_px": 1,
                         "third_channel": "ETD or SE, treated as the third detector at the user's direction",
                         "registration": "No registration or resampling; matched pixel grids assumed, separate alignment audit",
                         "clustering": "Three KMeans intensity classes; per-image channel z-scores; 100000 sampled pixels; BSE Multi-Otsu initial centres; seed42; n_init1",
                         "downstream": "Same 14 saved physical fields, including the separate heuristic Inlens median CBD allocation",
                         "silicon": "Bright phase identified as silicon by the user; intensity-derived boundaries still require validation",
                         "agreement": "Mask IoU, Dice and changed-pixel fractions measure agreement, not accuracy against ground truth",
                         "test_labels": "Not used in segmentation, measurement or settings"},
              "script_sha256": digest(__file__), "original_extractor_sha256": digest(PORTAL_ROOT / "scripts/extract_physical_features.py"),
              "source_hashes": source_hashes, "imagerep_revision": imagerep_revision,
              "versions": {"python": platform.python_version(), "numpy": np.__version__, "sklearn": sklearn.__version__,
                           "scipy": scipy.__version__, "skimage": skimage.__version__, "pillow": PIL.__version__,
                           "matplotlib": matplotlib.__version__},
              "samples": records}
    (args.run_dir / "report.json").write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
    print(f"Wrote {len(rows)} × 3 feature records to {args.run_dir}")
 
 
if __name__ == "__main__":
    main()
Batch assignment code (Python)

The executed assignment script. The reported confidence bands use the empirical_precision_v2 option specified in the protocol.

#!/usr/bin/env python3
"""Assign multichannel physical vectors with a fixed, transparent centroid rule.
 
First run without --test-csv to freeze the method and evaluate known crops.
Then supply --test-csv with the same output directory. No test labels are used.
Confidence bands describe heuristic assignment support, never probabilities or
manufacturing acceptance. All evaluation remains provisional at crop level.
"""
from __future__ import annotations
 
import argparse
import csv
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import platform
 
import numpy as np
 
 
CLASSES = ("Batch_1", "Batch_2", "Batch_3")
VARIANT = "stacked_three_channel"
FEATURES = (
    ("porosity_pct", "Pore-labelled area fraction", "%"),
    ("porosity_ci95_pct", "Porosity uncertainty half-width", "percentage points"),
    ("active_material_pct", "Graphite-labelled area fraction", "%"),
    ("cbd_pct", "Carbon-binder allocation", "%"),
    ("inclusion_pct", "Silicon-labelled area fraction", "%"),
    ("char_length_scale_cls_um", "Pore correlation length", "µm"),
    ("throat_through_plane_ly_um", "Vertical pore chord", "µm"),
    ("chord_in_plane_lx_um", "Horizontal pore chord", "µm"),
    ("pore_anisotropy_ratio", "Horizontal/vertical pore chord ratio", "ratio"),
    ("particle_aspect_ratio", "Silicon-labelled domain aspect ratio", "ratio"),
    ("particle_d50_um", "Silicon-labelled domain diameter D50", "µm"),
    ("particle_d90_um", "Silicon-labelled domain diameter D90", "µm"),
    ("slurry_dispersion_index", "Silicon-labelled area spatial variation", "percentage points"),
)
KEYS = tuple(item[0] for item in FEATURES)
# Fixed before inspecting the full test vectors. These are design choices,
# not empirically calibrated probability cutoffs or optimized hyperparameters.
THRESHOLDS = {
    "High": {"oof_precision_min": 0.85, "oof_prediction_count_min": 5,
             "relative_margin_min": 0.25, "leave_one_crop_out_agreement_min": 0.90},
    "Medium": {"oof_precision_min": 0.60, "oof_prediction_count_min": 5,
               "relative_margin_min": 0.10, "leave_one_crop_out_agreement_min": 0.75},
}
SUPPORT_QUANTILE = 0.95
POLICIES = ("conservative_v1", "empirical_precision_v2")
CONFIDENCE_POLICY_V2 = {
    "requested_by": "User", "threshold_source": "Explicit user instruction; no outcome-based threshold search",
    "revision_context": "Reporting bands revised after the v1 assignment summary. The fitted classifier, features and assignments are unchanged.",
    "score": "100 * correctly assigned known crops / all known crops predicted as the assigned class in leave-one-crop-out evaluation",
    "score_scope": "Class-level observed validation precision; not an individual calibrated probability",
    "thresholds": {"High": "strictly greater than70%", "Medium": "50% through70%, inclusive", "Low": "below50%"},
    "other_checks": "Distance margin, omission agreement and empirical support envelope remain diagnostics and do not change this user-defined band",
}
LIMITATIONS = [
    "The ratings are heuristic assignment support, not calibrated probabilities.",
    "Known crops may share original images; parent-image groups are unavailable. Leave-one-crop-out evaluation can therefore be optimistic.",
    "Per-class precision estimates are based on few crop predictions and unknown test class proportions.",
    "Thirteen equally standardized exported fields retain correlated and derived variables, including porosity uncertainty and the carbon-binder allocation. They are not thirteen independent physical mechanisms.",
    "The support envelope is an empirical distance summary, not a conformal prediction region or a 95% probability statement.",
    "Leave-one-crop-out agreement measures sensitivity to individual known crops, not correctness or uncertainty in phase boundaries.",
    "Silicon-labelled mask boundaries remain unvalidated and can include rims or lines; pore chords do not establish three-dimensional transport.",
    "Three earlier test identities have already been revealed. This is not an untouched external validation exercise; test labels are nevertheless excluded from fitting and confidence calculations.",
    "Batch identity does not establish manufacturing quality. No accept or reject decision is produced.",
]
 
 
def sha256(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()
 
 
def canonical_hash(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"),
                                     allow_nan=False).encode()).hexdigest()
 
 
def write_json(path, value):
    Path(path).write_text(json.dumps(value, indent=2, allow_nan=False) + "\n")
 
 
def read_vectors(path, *, known):
    """Read only the requested method and split; never read test-label columns."""
    records = []
    with Path(path).open(newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        header = reader.fieldnames or []
        required = {"batch", "sample_id", "variant", *KEYS}
        if len(header) != len(set(header)) or not required.issubset(header):
            raise ValueError("Unique CSV columns including batch, sample_id, variant and all13 fixed fields are required")
        seen = set()
        for row in reader:
            if None in row or any(value is None for value in row.values()):
                raise ValueError("Malformed CSV row")
            if row["variant"] != VARIANT:
                continue
            batch = row["batch"].strip()
            if known and batch not in CLASSES:
                continue
            if not known and batch in CLASSES:
                raise ValueError("Test input includes known-batch rows; provide the separate test extraction")
            sample = row["sample_id"].strip()
            if not sample or sample in seen:
                raise ValueError("Sample IDs must be nonempty and unique within the selected variant")
            seen.add(sample)
            values = [float(row[key]) for key in KEYS]
            if not np.isfinite(values).all():
                raise ValueError(f"Nonfinite vector: {sample}")
            records.append({"sample_id": sample, "batch": batch,
                            "third_channel": row.get("third_channel", ""), "values": values})
    if not records:
        raise ValueError("No vectors for the selected method and split")
    if known and (len(records) != 31 or {label: sum(r["batch"] == label for r in records)
                                          for label in CLASSES} != {"Batch_1": 7, "Batch_2": 7, "Batch_3": 17}):
        raise ValueError("This frozen study requires the original31 known crops (7,7,17)")
    return records
 
 
def fit_rule(x, y):
    mean = x.mean(axis=0)
    scale = x.std(axis=0, ddof=1)
    if not np.isfinite(scale).all() or np.any(scale <= 0):
        raise ValueError("All13 frozen fields must have positive training sample SD")
    centers = np.stack([x[y == label].mean(axis=0) for label in CLASSES])
    return {"mean": mean, "scale": scale, "centroids": centers}
 
 
def distances(rule, x):
    x = np.atleast_2d(x)
    squared = ((x[:, None, :] - rule["centroids"][None, :, :]) / rule["scale"]) ** 2
    return np.sqrt(squared.mean(axis=2)), squared
 
 
def relative_margin(values):
    order = np.argsort(values, kind="stable")
    best, runner = order[:2]
    # Equal zero distances are an exact tie, not maximal alignment.
    margin = float((values[runner] - values[best]) / values[runner]) if values[runner] > 0 else 0.0
    return int(best), int(runner), margin
 
 
def wilson_interval(correct, total):
    """Descriptive binomial interval only; shared parent crops violate its model."""
    if total == 0:
        return [None, None]
    z = 1.959963984540054
    p = correct / total
    denominator = 1 + z * z / total
    center = (p + z * z / (2 * total)) / denominator
    half = z * np.sqrt(p * (1 - p) / total + z * z / (4 * total * total)) / denominator
    return [float(center - half), float(center + half)]
 
 
def evaluate_known(records):
    x = np.array([r["values"] for r in records])
    y = np.array([r["batch"] for r in records])
    rows, omission_rules = [], []
    matrix = np.zeros((3, 3), dtype=int)
    for i, record in enumerate(records):
        keep = np.arange(len(records)) != i
        rule = fit_rule(x[keep], y[keep])
        omission_rules.append(rule)
        ds, _ = distances(rule, x[i])
        best, runner, margin = relative_margin(ds[0])
        actual = CLASSES.index(record["batch"])
        matrix[actual, best] += 1
        rows.append({"sample_id": record["sample_id"], "actual_batch": record["batch"],
                     "assigned_batch": CLASSES[best], "runner_up": CLASSES[runner],
                     "correct": best == actual, "relative_margin": margin,
                     "true_class_distance": float(ds[0, actual]),
                     "distances": dict(zip(CLASSES, ds[0].tolist()))})
    per_class = {}
    for j, label in enumerate(CLASSES):
        n_predicted, n_actual, correct = int(matrix[:, j].sum()), int(matrix[j].sum()), int(matrix[j, j])
        true_distances = [r["true_class_distance"] for r in rows if r["actual_batch"] == label]
        per_class[label] = {"actual_crop_count": n_actual, "oof_prediction_count": n_predicted,
                            "oof_correct_predictions": correct,
                            "oof_precision": correct / n_predicted if n_predicted else None,
                            "oof_recall": correct / n_actual,
                            "descriptive_binomial_precision_interval95": wilson_interval(correct, n_predicted),
                            "true_class_loo_distance_quantile95": float(np.quantile(true_distances, SUPPORT_QUANTILE, method="linear")),
                            "true_class_loo_distances": true_distances}
    evaluation = {"class_order": list(CLASSES), "confusion_matrix_actual_rows_predicted_columns": matrix.tolist(),
                  "correct": int(matrix.trace()), "crop_count": len(records),
                  "accuracy": float(matrix.trace() / len(records)),
                  "balanced_accuracy": float(np.mean(matrix.diagonal() / matrix.sum(axis=1))),
                  "per_class": per_class, "predictions": rows,
                  "precision_interval_note": "Wilson intervals assume independent Bernoulli outcomes; shown descriptively only because crop dependence is unresolved.",
                  "evaluation_scope": "Fixed-rule leave-one-known-crop-out; scaling and centroids refitted using the other30 crops in each fold."}
    return evaluation, fit_rule(x, y), omission_rules
 
 
def confidence_band(precision, count, margin, stability, within_support):
    checks = {}
    for name, thresholds in THRESHOLDS.items():
        checks[name] = {
            "class_oof_precision": precision is not None and precision >= thresholds["oof_precision_min"],
            "class_oof_prediction_count": count >= thresholds["oof_prediction_count_min"],
            "relative_margin": margin >= thresholds["relative_margin_min"],
            "leave_one_crop_out_agreement": stability >= thresholds["leave_one_crop_out_agreement_min"],
            "within_assigned_class_support": bool(within_support),
        }
    band = next((name for name in THRESHOLDS if all(checks[name].values())), "Low")
    return band, checks
 
 
def precision_rating(precision):
    if precision is None:
        return "Low"
    return "High" if precision > 0.70 else "Medium" if precision >= 0.50 else "Low"
 
 
def assign_tests(test, known, evaluation, rule, omission_rules, policy="conservative_v1"):
    x = np.array([r["values"] for r in test])
    known_x = np.array([r["values"] for r in known])
    known_y = np.array([r["batch"] for r in known])
    ds, squared = distances(rule, x)
    omission_predictions = np.array([np.argmin(distances(r, x)[0], axis=1) for r in omission_rules])
    assignments = []
    for i, record in enumerate(test):
        best, runner, margin = relative_margin(ds[i])
        label = CLASSES[best]
        validation = evaluation["per_class"][label]
        stability = float(np.mean(omission_predictions[:, i] == best))
        envelope = validation["true_class_loo_distance_quantile95"]
        inside = bool(ds[i, best] <= envelope)
        band, checks = confidence_band(validation["oof_precision"], validation["oof_prediction_count"], margin, stability, inside)
        # The summed contributions equal runner-up squared RMS distance minus
        # assigned squared RMS distance. Positive values favour the assignment.
        support = (squared[i, runner] - squared[i, best]) / len(KEYS)
        features = []
        for j, (key, name, unit) in enumerate(FEATURES):
            summaries = {}
            for batch in CLASSES:
                values = known_x[known_y == batch, j]
                summaries[batch] = {"mean": float(values.mean()), "sample_sd": float(values.std(ddof=1)),
                                    "min": float(values.min()), "max": float(values.max())}
            features.append({"key": key, "label": name, "unit": unit, "value": record["values"][j],
                             "known_batches": summaries,
                             "assigned_minus_runner_squared_distance_support": float(support[j]),
                             "standardized_difference_from_assigned_mean": float((x[i, j] - rule["centroids"][best, j]) / rule["scale"][j]),
                             "difference_from_batch3_mean": float(x[i, j] - rule["centroids"][2, j])})
        ranked = sorted(features, key=lambda f: f["assigned_minus_runner_squared_distance_support"], reverse=True)
        positive = [f for f in ranked if f["assigned_minus_runner_squared_distance_support"] > 0][:5]
        counter = [f for f in reversed(ranked) if f["assigned_minus_runner_squared_distance_support"] < 0][:3]
        record_out = {"sample_id": record["sample_id"], "third_channel": record["third_channel"],
                      "assigned_batch": label, "runner_up": CLASSES[runner],
                      "confidence": band, "confidence_meaning": "Heuristic assignment support, not probability or QC release",
                      "distances": dict(zip(CLASSES, ds[i].tolist())),
                      "relative_margin": margin,
                      "squared_distance_margin": float(ds[i, runner] ** 2 - ds[i, best] ** 2),
                      "leave_one_crop_out_agreement": stability,
                      "leave_one_crop_out_votes": {label_: int(np.sum(omission_predictions[:, i] == j)) for j, label_ in enumerate(CLASSES)},
                      "assigned_class_oof_precision": validation["oof_precision"],
                      "assigned_class_oof_prediction_count": validation["oof_prediction_count"],
                      "assigned_class_support_distance95": envelope,
                      "within_assigned_class_support": inside,
                      "confidence_checks": checks,
                      "unmet_medium_conditions": [key for key, passed in checks["Medium"].items() if not passed],
                      "top_supporting_features": positive,
                      "top_counter_features": counter,
                      "features": features,
                      "manufacturing_decision": "Not evaluated"}
        if policy == "empirical_precision_v2":
            precision = validation["oof_precision"]
            correct = validation["oof_correct_predictions"]
            total = validation["oof_prediction_count"]
            votes = record_out["leave_one_crop_out_votes"][label]
            explanation = (f"{correct} of {total} held-out known crops assigned to {label.replace('_', ' ')} were correctly identified "
                           f"({100 * precision:.1f}%). This is a class-level validation rate, not an individual probability. "
                           f"{votes} of 31 crop-omission refits retained this assignment.")
            if margin < 0.10:
                explanation += f" The nearest alternatives are close (relative distance margin {100 * margin:.1f}%)."
            if not inside:
                explanation += " This image is outside the assigned class's empirical distance envelope."
            record_out.update({"diagnostic_confidence_v1": band, "confidence": precision_rating(precision),
                               "confidence_meaning": "User-defined band of class-level observed validation precision; not individual probability",
                               "confidence_pct": 100 * precision,
                               "validation_rate_pct": 100 * precision,
                               "validation_n_correct": correct, "validation_n_predictions": total,
                               "explanation": explanation})
        if not np.isclose(sum(f["assigned_minus_runner_squared_distance_support"] for f in features),
                          record_out["squared_distance_margin"], rtol=1e-10, atol=1e-12):
            raise AssertionError("Feature contributions do not sum to the squared-distance margin")
        assignments.append(record_out)
    return assignments
 
 
def export_assignment_csv(path, assignments, policy="conservative_v1"):
    if policy == "empirical_precision_v2":
        fields = ["sample_id", "assigned_batch", "confidence", "validation_rate_pct", "explanation"]
        with Path(path).open("w", newline="") as handle:
            writer = csv.DictWriter(handle, fieldnames=fields)
            writer.writeheader()
            writer.writerows({key: record[key] for key in fields} for record in assignments)
        return
    fields = ["sample_id", "third_channel", "assigned_batch", "runner_up", "confidence",
              "distance_Batch_1", "distance_Batch_2", "distance_Batch_3", "relative_margin",
              "leave_one_crop_out_agreement", "assigned_class_oof_precision", "assigned_class_oof_prediction_count",
              "assigned_class_support_distance95", "within_assigned_class_support", "unmet_medium_conditions",
              "top_supporting_features", *KEYS]
    with Path(path).open("w", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fields)
        writer.writeheader()
        for assignment in assignments:
            row = {key: assignment[key] for key in fields if key in assignment}
            row.update({f"distance_{label}": assignment["distances"][label] for label in CLASSES})
            row.update({f["key"]: f["value"] for f in assignment["features"]})
            row["unmet_medium_conditions"] = "; ".join(assignment["unmet_medium_conditions"])
            row["top_supporting_features"] = "; ".join(f'{f["label"]}: {f["value"]:.6g} {f["unit"]}' for f in assignment["top_supporting_features"])
            writer.writerow(row)
 
 
def export_feature_csv(path, assignments):
    fields = ["sample_id", "assigned_batch", "runner_up", "feature", "label", "unit", "value",
              "assigned_minus_runner_squared_distance_support", "difference_from_batch3_mean"]
    fields += [f"{batch}_{measure}" for batch in CLASSES for measure in ("mean", "sample_sd", "min", "max")]
    with Path(path).open("w", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fields)
        writer.writeheader()
        for record in assignments:
            for feature in record["features"]:
                row = {key: record[key] for key in ("sample_id", "assigned_batch", "runner_up")}
                row.update({key: feature[key] for key in ("label", "unit", "value", "assigned_minus_runner_squared_distance_support", "difference_from_batch3_mean")})
                row["feature"] = feature["key"]
                for batch, summary in feature["known_batches"].items():
                    row.update({f"{batch}_{key}": value for key, value in summary.items()})
                writer.writerow(row)
 
 
def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--known-csv", type=Path, required=True)
    parser.add_argument("--test-csv", type=Path)
    parser.add_argument("--output-dir", type=Path, required=True)
    parser.add_argument("--confidence-policy", choices=POLICIES, default="conservative_v1")
    args = parser.parse_args()
    args.output_dir.mkdir(parents=True, exist_ok=True)
    specification = {
        "method": "Fixed nearest batch centroid using equally standardized RMS distance",
        "segmentation_variant": VARIANT, "class_order": list(CLASSES),
        "features": [{"key": key, "label": label, "unit": unit} for key, label, unit in FEATURES],
        "excluded_field": "particle_d10_um, omitted in both methods to retain the previously compared common13 fields",
        "scaling": "Sample standard deviation (ddof=1) across known training crops only; refitted inside each leave-one-out fold",
        "tie_rule": "First class in Batch_1, Batch_2, Batch_3 order; exact ties have zero relative margin",
        "relative_margin_formula": "(runner_up_distance - nearest_distance) / runner_up_distance; zero for an all-zero tie",
        "confidence_thresholds": THRESHOLDS,
        "confidence_combination": "Every condition must pass; High checked before Medium; otherwise Low",
        "support_envelope": "Assigned-class distance <=95th percentile (linear interpolation) of known true-class leave-one-crop-out distances",
        "stability": "Fraction of31 leave-one-known-crop-out refits retaining the full-fit assignment",
        "feature_contributions": "(runner-up squared standardized deviation minus assigned squared standardized deviation)/13; sum equals runner-up squared RMS distance minus assigned squared RMS distance",
        "limitations": LIMITATIONS,
    }
    specification["confidence_policy"] = args.confidence_policy
    if args.confidence_policy == "empirical_precision_v2":
        specification["confidence_policy_v2"] = CONFIDENCE_POLICY_V2
        specification["diagnostic_thresholds_v1"] = specification.pop("confidence_thresholds")
        specification["confidence_thresholds"] = CONFIDENCE_POLICY_V2["thresholds"]
        specification["confidence_combination"] = "Band the class-level observed validation precision using the user's cutoffs; geometry diagnostics do not alter the band"
    freeze_path = args.output_dir / "frozen_method.json"
    signature = {"specification_sha256": canonical_hash(specification), "known_csv_sha256": sha256(args.known_csv),
                 "script_sha256": sha256(__file__)}
    if freeze_path.exists():
        frozen = json.loads(freeze_path.read_text())
        if frozen["signature"] != signature:
            raise ValueError("Frozen method/source/known input changed; use a separate versioned output directory, never silently retune")
    else:
        if args.test_csv:
            raise ValueError("Freeze and evaluate using known data first: run once without --test-csv")
        frozen = {"frozen_utc": datetime.now(timezone.utc).isoformat(), "signature": signature,
                  "specification": specification, "runtime": {"python": platform.python_version(), "numpy": np.__version__}}
        write_json(freeze_path, frozen)
    known = read_vectors(args.known_csv, known=True)
    evaluation, rule, omission_rules = evaluate_known(known)
    write_json(args.output_dir / "known_evaluation.json", evaluation)
    with (args.output_dir / "known_loo.csv").open("w", newline="") as handle:
        fields = ["sample_id", "actual_batch", "assigned_batch", "runner_up", "correct", "relative_margin", "true_class_distance"] + [f"distance_{label}" for label in CLASSES]
        writer = csv.DictWriter(handle, fieldnames=fields)
        writer.writeheader()
        for record in evaluation["predictions"]:
            row = {key: record[key] for key in fields if key in record}
            row.update({f"distance_{label}": value for label, value in record["distances"].items()})
            writer.writerow(row)
    fitted = {key: value.tolist() for key, value in rule.items()}
    fitted.update({"feature_order": list(KEYS), "class_order": list(CLASSES), "known_sample_ids": [r["sample_id"] for r in known]})
    write_json(args.output_dir / "fitted_centroids.json", fitted)
    print(json.dumps({"known_correct": evaluation["correct"], "known_total": evaluation["crop_count"],
                      "accuracy": evaluation["accuracy"], "balanced_accuracy": evaluation["balanced_accuracy"],
                      "per_class": {key: {field: value for field, value in values.items() if field != "true_class_loo_distances"}
                                    for key, values in evaluation["per_class"].items()}}, indent=2))
    if not args.test_csv:
        return
    test = read_vectors(args.test_csv, known=False)
    if set(r["sample_id"] for r in known) & set(r["sample_id"] for r in test):
        raise ValueError("Known and test sample IDs overlap")
    assignments = assign_tests(test, known, evaluation, rule, omission_rules, args.confidence_policy)
    result = {"frozen_method": frozen, "test_csv_sha256": sha256(args.test_csv),
              "test_sample_count": len(test), "known_evaluation": evaluation,
              "assignment_counts": {label: sum(r["assigned_batch"] == label for r in assignments) for label in CLASSES},
              "confidence_counts": {level: sum(r["confidence"] == level for r in assignments) for level in ("High", "Medium", "Low")},
              "assignments": assignments}
    if args.confidence_policy == "empirical_precision_v2":
        result["confidence_policy_v2"] = CONFIDENCE_POLICY_V2
    write_json(args.output_dir / "assignments.json", result)
    export_assignment_csv(args.output_dir / "assignments.csv", assignments, args.confidence_policy)
    export_feature_csv(args.output_dir / "feature_evidence.csv", assignments)
    print(json.dumps({"test_samples": len(test), "assignment_counts": result["assignment_counts"],
                      "confidence_counts": result["confidence_counts"]}, indent=2))
 
 
if __name__ == "__main__":
    main()

4. Results

Combined three-detector results for all nine test samples. Assignments and confidence are shown first, followed by the physical measurements.

Test-image assignments and confidence

The combined physical measurements assign 9 images to the nearest known batch. Confidence uses the observed validation rate for that predicted batch: High above 70%, Medium from 50% to 70%, and Low below 50%. Images assigned to the same batch share the same rate.

Percentages are class-level leave-one-crop-out validation rates, not individual probabilities.
Sample IDAssigned batchConfidence
0eryguqqBatch 3High (86.7%)
3e122cbjBatch 1Medium (50%)
4hq27w4cBatch 1Medium (50%)
fhwrjtetBatch 3High (86.7%)
fn0mhxefBatch 2Low (20%)
fspqbkxlBatch 2Low (20%)
soo2ax3rBatch 1Medium (50%)
xrv9xvzbBatch 3High (86.7%)
y59rxmxlBatch 2Low (20%)
How the confidence ratings were calculated

Omit one of the 31 known image crops, refit scaling and batch means on the other 30, and predict the omitted crop. Repeat for every known crop. For each predicted batch, divide correct assignments by all assignments to that batch. The table shows the resulting rates and the requested reporting bands.

Predicted batchCorrect / assignedValidation rateBandBand threshold
Batch 313 / 1586.7%High> 70%
Batch 13 / 650%Medium50% to 70%, inclusive
Batch 22 / 1020%Low< 50%

This is an empirical rate for a predicted class, not a calibrated probability for an individual image. A strong geometric match and an uncertain validation rate can occur together. Refitting stability and distance checks remain diagnostics and do not alter these bands. Rates depend on the class composition of the known data.

The organisers confirmed 3e122cbj as Batch 2, fn0mhxef as Batch 1, and xrv9xvzb as Batch 3. The assignments disagree with the first two and agree with the third. These labels were not used to fit the classifier or calculate the confidence rates.

The rates use small samples and may be optimistic if crops share parent images. The 13 inputs include correlated pore fraction, carbon-binder allocation and uncertainty fields; they are not independent evidence. The six unlabelled images cannot yet be scored for accuracy, and the three labelled follow-ups are not a new unseen validation set. Batch assignment does not establish manufacturing acceptance.

Measurements behind each assignment

All 13 assignment inputs are shown in their original units against the assigned batch mean and Batch 3 reference mean. The assignment uses their combined standardised distance, rather than any single measurement.

0eryguqq: Batch 3

Pore-labelled area fraction: 16.57%; Batch 3 mean 14.29%.

Graphite-labelled area fraction: 62.29%; Batch 3 mean 64.74%.

MeasurementImage valueBatch 3 meanBatch 3 mean
Pore-labelled area fraction16.57%14.29%14.29%
Porosity uncertainty half-width2.04 percentage points1.69 percentage points1.69 percentage points
Graphite-labelled area fraction62.29%64.74%64.74%
Carbon-binder allocation8.29%7.15%7.15%
Silicon-labelled area fraction21.14%20.96%20.96%
Pore correlation length2.101 µm2.048 µm2.048 µm
Vertical pore chord0.525 µm0.519 µm0.519 µm
Horizontal pore chord0.630 µm0.594 µm0.594 µm
Horizontal/vertical pore chord ratio1.201.141.14
Silicon-labelled domain aspect ratio3.193.153.15
Silicon-labelled domain diameter D500.164 µm0.178 µm0.178 µm
Silicon-labelled domain diameter D900.579 µm0.655 µm0.655 µm
Silicon-labelled area spatial variation6.30 percentage points5.64 percentage points5.64 percentage points
3e122cbj: Batch 1

Silicon-labelled domain aspect ratio: 3.53; Batch 1 mean 3.21.

Vertical pore chord: 0.373 µm; Batch 1 mean 0.422 µm.

MeasurementImage valueBatch 1 meanBatch 3 mean
Pore-labelled area fraction9.91%9.56%14.29%
Porosity uncertainty half-width1.53 percentage points1.32 percentage points1.69 percentage points
Graphite-labelled area fraction69.13%74.05%64.74%
Carbon-binder allocation4.96%4.78%7.15%
Silicon-labelled area fraction20.96%16.39%20.96%
Pore correlation length2.358 µm1.958 µm2.048 µm
Vertical pore chord0.373 µm0.422 µm0.519 µm
Horizontal pore chord0.410 µm0.487 µm0.594 µm
Horizontal/vertical pore chord ratio1.101.151.14
Silicon-labelled domain aspect ratio3.533.213.15
Silicon-labelled domain diameter D500.160 µm0.172 µm0.178 µm
Silicon-labelled domain diameter D900.487 µm0.529 µm0.655 µm
Silicon-labelled area spatial variation6.64 percentage points5.22 percentage points5.64 percentage points
4hq27w4c: Batch 1

Horizontal/vertical pore chord ratio: 1.29; Batch 1 mean 1.15.

Vertical pore chord: 0.354 µm; Batch 1 mean 0.422 µm.

MeasurementImage valueBatch 1 meanBatch 3 mean
Pore-labelled area fraction5.95%9.56%14.29%
Porosity uncertainty half-width0.89 percentage points1.32 percentage points1.69 percentage points
Graphite-labelled area fraction80.23%74.05%64.74%
Carbon-binder allocation2.98%4.78%7.15%
Silicon-labelled area fraction13.82%16.39%20.96%
Pore correlation length1.671 µm1.958 µm2.048 µm
Vertical pore chord0.354 µm0.422 µm0.519 µm
Horizontal pore chord0.458 µm0.487 µm0.594 µm
Horizontal/vertical pore chord ratio1.291.151.14
Silicon-labelled domain aspect ratio3.053.213.15
Silicon-labelled domain diameter D500.174 µm0.172 µm0.178 µm
Silicon-labelled domain diameter D900.536 µm0.529 µm0.655 µm
Silicon-labelled area spatial variation6.35 percentage points5.22 percentage points5.64 percentage points
fhwrjtet: Batch 3

Pore-labelled area fraction: 16.13%; Batch 3 mean 14.29%.

Horizontal/vertical pore chord ratio: 1.25; Batch 3 mean 1.14.

MeasurementImage valueBatch 3 meanBatch 3 mean
Pore-labelled area fraction16.13%14.29%14.29%
Porosity uncertainty half-width2.02 percentage points1.69 percentage points1.69 percentage points
Graphite-labelled area fraction63.05%64.74%64.74%
Carbon-binder allocation8.06%7.15%7.15%
Silicon-labelled area fraction20.82%20.96%20.96%
Pore correlation length2.105 µm2.048 µm2.048 µm
Vertical pore chord0.482 µm0.519 µm0.519 µm
Horizontal pore chord0.602 µm0.594 µm0.594 µm
Horizontal/vertical pore chord ratio1.251.141.14
Silicon-labelled domain aspect ratio3.143.153.15
Silicon-labelled domain diameter D500.174 µm0.178 µm0.178 µm
Silicon-labelled domain diameter D900.666 µm0.655 µm0.655 µm
Silicon-labelled area spatial variation6.07 percentage points5.64 percentage points5.64 percentage points
fn0mhxef: Batch 2

Silicon-labelled domain diameter D90: 0.646 µm; Batch 2 mean 0.589 µm.

Silicon-labelled domain aspect ratio: 2.92; Batch 2 mean 3.02.

MeasurementImage valueBatch 2 meanBatch 3 mean
Pore-labelled area fraction8.93%10.71%14.29%
Porosity uncertainty half-width1.15 percentage points1.42 percentage points1.69 percentage points
Graphite-labelled area fraction73.66%70.88%64.74%
Carbon-binder allocation4.46%5.36%7.15%
Silicon-labelled area fraction17.41%18.41%20.96%
Pore correlation length1.738 µm2.017 µm2.048 µm
Vertical pore chord0.476 µm0.488 µm0.519 µm
Horizontal pore chord0.534 µm0.547 µm0.594 µm
Horizontal/vertical pore chord ratio1.121.121.14
Silicon-labelled domain aspect ratio2.923.023.15
Silicon-labelled domain diameter D500.187 µm0.174 µm0.178 µm
Silicon-labelled domain diameter D900.646 µm0.589 µm0.655 µm
Silicon-labelled area spatial variation4.82 percentage points5.38 percentage points5.64 percentage points
fspqbkxl: Batch 2

Pore-labelled area fraction: 9.50%; Batch 2 mean 10.71%.

Graphite-labelled area fraction: 75.10%; Batch 2 mean 70.88%.

MeasurementImage valueBatch 2 meanBatch 3 mean
Pore-labelled area fraction9.50%10.71%14.29%
Porosity uncertainty half-width1.52 percentage points1.42 percentage points1.69 percentage points
Graphite-labelled area fraction75.10%70.88%64.74%
Carbon-binder allocation4.75%5.36%7.15%
Silicon-labelled area fraction15.41%18.41%20.96%
Pore correlation length2.297 µm2.017 µm2.048 µm
Vertical pore chord0.516 µm0.488 µm0.519 µm
Horizontal pore chord0.577 µm0.547 µm0.594 µm
Horizontal/vertical pore chord ratio1.121.121.14
Silicon-labelled domain aspect ratio3.133.023.15
Silicon-labelled domain diameter D500.185 µm0.174 µm0.178 µm
Silicon-labelled domain diameter D900.692 µm0.589 µm0.655 µm
Silicon-labelled area spatial variation6.43 percentage points5.38 percentage points5.64 percentage points
soo2ax3r: Batch 1

Silicon-labelled domain aspect ratio: 3.44; Batch 1 mean 3.21.

Horizontal/vertical pore chord ratio: 1.19; Batch 1 mean 1.15.

MeasurementImage valueBatch 1 meanBatch 3 mean
Pore-labelled area fraction10.65%9.56%14.29%
Porosity uncertainty half-width1.22 percentage points1.32 percentage points1.69 percentage points
Graphite-labelled area fraction75.43%74.05%64.74%
Carbon-binder allocation5.32%4.78%7.15%
Silicon-labelled area fraction13.92%16.39%20.96%
Pore correlation length1.750 µm1.958 µm2.048 µm
Vertical pore chord0.441 µm0.422 µm0.519 µm
Horizontal pore chord0.526 µm0.487 µm0.594 µm
Horizontal/vertical pore chord ratio1.191.151.14
Silicon-labelled domain aspect ratio3.443.213.15
Silicon-labelled domain diameter D500.178 µm0.172 µm0.178 µm
Silicon-labelled domain diameter D900.594 µm0.529 µm0.655 µm
Silicon-labelled area spatial variation3.98 percentage points5.22 percentage points5.64 percentage points
xrv9xvzb: Batch 3

Silicon-labelled domain diameter D90: 0.732 µm; Batch 3 mean 0.655 µm.

Silicon-labelled domain aspect ratio: 3.46; Batch 3 mean 3.15.

MeasurementImage valueBatch 3 meanBatch 3 mean
Pore-labelled area fraction12.22%14.29%14.29%
Porosity uncertainty half-width1.62 percentage points1.69 percentage points1.69 percentage points
Graphite-labelled area fraction71.37%64.74%64.74%
Carbon-binder allocation6.11%7.15%7.15%
Silicon-labelled area fraction16.41%20.96%20.96%
Pore correlation length2.156 µm2.048 µm2.048 µm
Vertical pore chord0.531 µm0.519 µm0.519 µm
Horizontal pore chord0.604 µm0.594 µm0.594 µm
Horizontal/vertical pore chord ratio1.141.141.14
Silicon-labelled domain aspect ratio3.463.153.15
Silicon-labelled domain diameter D500.183 µm0.178 µm0.178 µm
Silicon-labelled domain diameter D900.732 µm0.655 µm0.655 µm
Silicon-labelled area spatial variation5.32 percentage points5.64 percentage points5.64 percentage points
y59rxmxl: Batch 2

Pore-labelled area fraction: 10.29%; Batch 2 mean 10.71%.

Graphite-labelled area fraction: 71.65%; Batch 2 mean 70.88%.

Weak match: outside the known Batch 2 distance limit; 25 of 31 refits retain the assignment.

MeasurementImage valueBatch 2 meanBatch 3 mean
Pore-labelled area fraction10.29%10.71%14.29%
Porosity uncertainty half-width1.93 percentage points1.42 percentage points1.69 percentage points
Graphite-labelled area fraction71.65%70.88%64.74%
Carbon-binder allocation5.15%5.36%7.15%
Silicon-labelled area fraction18.05%18.41%20.96%
Pore correlation length2.622 µm2.017 µm2.048 µm
Vertical pore chord0.515 µm0.488 µm0.519 µm
Horizontal pore chord0.646 µm0.547 µm0.594 µm
Horizontal/vertical pore chord ratio1.251.121.14
Silicon-labelled domain aspect ratio2.863.023.15
Silicon-labelled domain diameter D500.176 µm0.174 µm0.178 µm
Silicon-labelled domain diameter D900.596 µm0.589 µm0.655 µm
Silicon-labelled area spatial variation6.07 percentage points5.38 percentage points5.64 percentage points
Assignment table (CSV)Assignment data (JSON)

Three-detector measurements

Measurements from 31 known images and 9 test images use BSE, Inlens and ETD/SE jointly. All samples share the same preparation, segmentation and physical measurement settings.

Some bright edges and rims enter the silicon-labelled mask. Phase boundaries remain unvalidated, and this segmentation uncertainty is not included in the porosity interval.

Combined measurements for all 9 test images. Known batches show mean ± sample standard deviation; each test column is one image. Scroll horizontally to inspect every image.
MeasurementBatch 3n = 17Batch 1n = 7Batch 2n = 7Test image0eryguqqTest image3e122cbjTest image4hq27w4cTest imagefhwrjtetTest imagefn0mhxefTest imagefspqbkxlTest imagesoo2ax3rTest imagexrv9xvzbTest imagey59rxmxlUnits
Pore area fraction14.29 ± 2.439.56 ± 1.8710.71 ± 1.7516.579.915.9516.138.939.5010.6512.2210.29%
Graphite-labelled area fraction64.74 ± 5.9574.05 ± 5.1170.88 ± 5.6562.2969.1380.2363.0573.6675.1075.4371.3771.65%
Silicon-labelled area fraction20.96 ± 4.3916.39 ± 4.9018.41 ± 4.7021.1420.9613.8220.8217.4115.4113.9216.4118.05%
Vertical pore chord length0.519 ± 0.0510.422 ± 0.0610.488 ± 0.0620.5250.3730.3540.4820.4760.5160.4410.5310.515µm
Silicon-labelled component aspect ratio3.15 ± 0.233.21 ± 0.223.02 ± 0.313.193.533.053.142.923.133.443.462.86ratio
All combined measurements: known batches and individual test images

Known batches show mean ± sample standard deviation between images. Each test column contains one image measurement. The 14 rows contain 13 physical descriptors and the separate porosity uncertainty half-width.

MeasurementUnitsBatch 3Batch 1Batch 2Test image0eryguqqTest image3e122cbjTest image4hq27w4cTest imagefhwrjtetTest imagefn0mhxefTest imagefspqbkxlTest imagesoo2ax3rTest imagexrv9xvzbTest imagey59rxmxl
Pore area fraction%14.29 ± 2.439.56 ± 1.8710.71 ± 1.7516.579.915.9516.138.939.5010.6512.2210.29
Porosity uncertainty half-widthpercentage points1.69 ± 0.321.32 ± 0.281.42 ± 0.272.041.530.892.021.151.521.221.621.93
Graphite-labelled area fraction%64.74 ± 5.9574.05 ± 5.1170.88 ± 5.6562.2969.1380.2363.0573.6675.1075.4371.3771.65
Carbon-binder allocation%7.15 ± 1.214.78 ± 0.945.36 ± 0.888.294.962.988.064.464.755.326.115.15
Silicon-labelled area fraction%20.96 ± 4.3916.39 ± 4.9018.41 ± 4.7021.1420.9613.8220.8217.4115.4113.9216.4118.05
Pore correlation length scaleµm2.048 ± 0.2511.958 ± 0.3012.017 ± 0.2312.1012.3581.6712.1051.7382.2971.7502.1562.622
Vertical pore chord lengthµm0.519 ± 0.0510.422 ± 0.0610.488 ± 0.0620.5250.3730.3540.4820.4760.5160.4410.5310.515
Horizontal pore chord lengthµm0.594 ± 0.0680.487 ± 0.0930.547 ± 0.0610.6300.4100.4580.6020.5340.5770.5260.6040.646
Pore chord anisotropyratio1.145 ± 0.0441.149 ± 0.0741.122 ± 0.0351.1981.0981.2921.2491.1211.1181.1921.1371.254
Silicon-labelled component aspect ratioratio3.15 ± 0.233.21 ± 0.223.02 ± 0.313.193.533.053.142.923.133.443.462.86
Silicon-labelled component diameter D10µm0.098 ± 0.0010.098 ± 0.0000.098 ± 0.0020.0980.0980.0980.0980.0980.0980.0980.0980.098
Silicon-labelled component diameter D50µm0.178 ± 0.0070.172 ± 0.0110.174 ± 0.0090.1640.1600.1740.1740.1870.1850.1780.1830.176
Silicon-labelled component diameter D90µm0.655 ± 0.0560.529 ± 0.0650.589 ± 0.0610.5790.4870.5360.6660.6460.6920.5940.7320.596
Silicon-labelled area spatial variationpercentage points5.64 ± 1.045.22 ± 1.395.38 ± 0.496.306.646.356.074.826.433.985.326.07

Porosity uncertainty is conditional on the chosen mask and does not include the effect of changing segmentation. Carbon-binder allocation remains an Inlens median split within that mask, not an independently measured phase.

Known-image measurements (CSV)Test-image measurements (CSV)Comparison data (JSON)

How the assignment comparison was calculated

Calculate a mean vector for each known batch. Divide each input by its sample standard deviation across all 31 known images, then assign a test image to the batch mean with the smallest root-mean-square standardised distance. Scaling and batch means use known images only. Confirmed test labels are used only to check the assignments.

The assignment uses a fixed subset of 13 saved fields: all fields except D10. It includes porosity uncertainty and derived, correlated measurements. This differs from the 13-descriptor physical vector, which includes D10 and reports uncertainty separately.

No class probabilities or acceptance thresholds are fitted. A nearest batch identifies the closest of the three known means; it does not establish equivalence to that batch or suitability for manufacturing.

5. Discussion and limitations

With combined segmentation, mean pore area is Batch 3: 14.29%; Batch 1: 9.56%; Batch 2: 10.71%. These measurements describe the observed crops. The classifier remains uncertain between Batches 1 and 2: two of the three test assignments with organiser feedback are incorrect. Labels for the other six samples have not been supplied.

Bright rims and lines can enter the silicon-labelled class. These boundaries affect area fractions, connected-component geometry and pore measurements. ImageRep uncertainty is conditional on each mask and does not include phase-assignment error. Independently assessed phase boundaries are needed to validate segmentation accuracy.

Crops from the same electrode image may share structure. Validation should hold out entire source images where their identities are known; a random split of crops can overstate performance. The current leave-one-crop-out validation may therefore overestimate performance on independent electrode images.

The 13 physical descriptors are not independent: the anisotropy ratio is derived from two chord lengths, and the CBD allocation is tied to pore fraction. The current distance rule also includes porosity uncertainty and omits D10. Correlated quantities receive separate contributions, so the inputs do not provide 13 independent pieces of evidence.

Scope relative to published electrode analyses

Polaron's solid-state electrode case study separates phase identity, interfacial contact, connectivity and transport. It uses segmented images and reconstructed three-dimensional volumes. In solid-state electrodes, contact with solid electrolyte is central; pores do not have the same role as electrolyte-filled pore space in a conventional cell.

This report measures two-dimensional area fractions, pore chords and correlation scale, and the size, shape and spatial variation of silicon particles. It does not measure phase-specific interfaces, graphite-particle alignment, three-dimensional connectivity or tortuosity. These are possible extensions requiring additional calculations or validation.

The literature motivates the physical questions. It does not establish that this particular descriptor set detects manufacturing defects. That claim requires evaluation against independent batch outcomes.

6. Reproducibility and test-set use

All 31 known samples and 9 test samples use the same preparation, segmentation and measurement settings. Repeated extraction of three samples reproduces all 42 reported measurement values and three joint masks exactly. Input hashes, software versions, source snapshots and full-precision vectors document the calculations.

Complete test-set processing
StepCurrent status
Physical extractionComplete for all 9 test samples using three-detector segmentation.
Reference comparisonsAll 13 descriptors compared with Batches 1, 2 and 3 in their measured units.
Batch assignmentNearest standardized known-batch mean. Confidence bands and their validation basis are listed with each assignment.
ValidationLeave-one-crop-out checks are provisional because crops can share source images. Class-level validation rates are not individual probabilities.
Test-set procedure

Nearest-batch assignment

The fixed classifier uses 13 assignment inputs: all 14 saved fields except D10. This is different from the 13 physical descriptors: it includes porosity uncertainty and excludes D10. The inputs retain correlated phase fractions, the CBD allocation and derived chord anisotropy; equal numerical weights do not make them independent physical evidence.

Fit one mean vector per known batch and calculate each input's sample standard deviation across all 31 known crops (ddof=1). Assign each test vector to the batch with the smallest root-mean-square standardized distance:

db=113∑j=113(xj−xˉbjsj)2.d_b=\sqrt{\frac{1}{13}\sum_{j=1}^{13}\left(\frac{x_j-\bar{x}_{bj}}{s_j}\right)^2}.

Known and test vectors must use the same three-detector segmentation. Scaling and batch means use known crops only; no test label enters fitting. An exact distance tie selects the first batch in the fixed order Batch 1, Batch 2, Batch 3. The rule identifies the closest known batch mean, not manufacturing equivalence. Assignment calculation.

Assignment validation and confidence

Leave out one known crop, refit scaling and batch means on the other 30, then classify the omitted crop. Repeat for all 31 crops. The fixed rule correctly assigns 18 of 31 crops (58.1% accuracy; 49.3% balanced accuracy). For each predicted batch, divide correct assignments by all assignments to that batch:

Predicted batchCorrect / predictedObserved validation rateConfidence band
Batch 13 / 650.0%Medium
Batch 22 / 1020.0%Low
Batch 313 / 1586.7%High

The reporting bands are High above 70%, Medium from 50% to 70% inclusive, and Low below 50%. They summarize class-level observed precision, not calibrated probabilities for individual images. Images assigned to the same batch share the same rate. Rates depend on the validation class composition and small sample counts.

Distance margins, agreement across 31 crop-omission refits and the assigned class's empirical distance envelope are retained as diagnostics. They do not change these reporting bands. Validation calculation; reporting thresholds.

The organisers describe crops from approximately 15 source electrode images, arranged into batches. A crop-to-parent mapping is unavailable, so crop-level validation may be optimistic. Future validation should hold out entire source images when that mapping is known.

Supplied labels identify 3e122cbj as Batch 2, fn0mhxef as Batch 1 and xrv9xvzb as Batch 3. The classifier assigns them to Batches 1, 2 and 3 respectively. These labels were available before the complete test evaluation and were not used to fit the classifier or calculate its confidence rates. Labels for the other six images have not been supplied. Batch assignment and its confidence band do not establish manufacturing acceptance.

Apply the pipeline to new images

Arrange detector triplets in a named folder under a data directory: img_<sample>_BSE.tif, img_<sample>_Inlens.tif and img_<sample>_ETD.tif (or _SE.tif). Check detector correspondence and use the same acquisition scale and specimen orientation as the reference.

Run from the portal repository with NumPy, SciPy, scikit-image, scikit-learn, Matplotlib, Pillow and the recorded ImageRep checkout available:

python scripts/run_multichannel_experiment.py \
  --data-dir /path/to/data \
  --batch-name test_1 \
  --imagerep-path /path/to/ImageRep \
  --run-dir /path/to/new-extraction

Use a new run directory. The reported method is identified by variant=stacked_three_channel in features.csv. Extraction retains full-precision measurements, full-crop masks, diagnostic figures, input hashes, fitted segmentation centroids, settings, software versions and source snapshots. Diagnostic outputs do not change which variant the classifier selects.

First evaluate and record the assignment rule using known data, then apply it to the extracted test vectors in the same assignment output directory:

python scripts/assign_multichannel_tests.py \
  --known-csv public/multichannel/known_batches.csv \
  --confidence-policy empirical_precision_v2 \
  --output-dir /path/to/new-assignments

python scripts/assign_multichannel_tests.py \
  --known-csv public/multichannel/known_batches.csv \
  --test-csv /path/to/new-extraction/features.csv \
  --confidence-policy empirical_precision_v2 \
  --output-dir /path/to/new-assignments

The assignment script requires NumPy and selects only combined-method rows. It checks the fixed 31-known-crop cohort, distinct known/test identifiers, finite inputs and positive training standard deviations. It records the fitted centroids, validation predictions, feature contributions and a frozen method signature. Reusing an output directory with a changed source, specification or known input is rejected. To reproduce the reported nine assignments, use public/multichannel/test_1.csv as --test-csv.

Combined-method verification and software versions

The current report contains 31 known images and 9 test images. Summary generation checked 1,806 numerical entries against the extraction records, with a maximum absolute difference of 0.

Repeated extraction returned 126 identical numerical values and 9 identical masks. These are reproducibility checks, not validation of the assigned material phases.

python
3.13.7
numpy
1.26.4
sklearn
1.9.1
scipy
1.17.1
skimage
0.26.0
pillow
12.3.0
matplotlib
3.11.2
ImageRep revision
2c51ffbcd3f9bc00237f4348e7063d92f7efbfb0
Settings, source hashes and checks
{
  "method": {
    "pixel_size_um": 0.025,
    "crop": "central 80% rows, all columns",
    "gaussian_sigma_px": 1,
    "third_channel": "ETD or SE, treated as the third detector at the user's direction",
    "registration": "No registration or resampling; matched pixel grids assumed, separate alignment audit",
    "clustering": "Three KMeans intensity classes; per-image channel z-scores; 100000 sampled pixels; BSE Multi-Otsu initial centres; seed42; n_init1",
    "downstream": "Same 14 saved physical fields, including the separate heuristic Inlens median CBD allocation",
    "silicon": "Bright phase identified as silicon by the user; intensity-derived boundaries still require validation",
    "agreement": "Mask IoU, Dice and changed-pixel fractions measure agreement, not accuracy against ground truth",
    "test_labels": "Not used in segmentation, measurement or settings"
  },
  "sources": {
    "features_csv_sha256": "3c782daea78862d3cbda83433d231e8412a126c7dda130af1696f22b2d5e0916",
    "report_json_sha256": "0b5eeb64d9633b64733459767063463a983b326eed7bb94fb73794ec845afc30",
    "confirmed_labels_sha256": "40b11cc36d328d3f326e6e962d6fca5f344fb8e07f5ab1e62707e4b2e8a30bb1",
    "generator_sha256": "52716317e27a9629fcff1a7b00669e355e11a6e2adb1fe59afdb91d540f6c559",
    "test_features_csv_sha256": "582886a16445b227ccc5291090d677076963569fd6e4b8e654f726cdeaba02c1",
    "test_report_json_sha256": "192353a3bff08f639c1b27dc34594356edb2228ffa07c8ecf409b207379bf13a"
  },
  "exports": {
    "original_known.csv": "fb9a5cc754d59b8a67323e6b13439ce07aa900fa2333a274d7a591b5f66469e7",
    "known_batches.csv": "350e6a20908db73121e88a43f222e3018395cd3f0d92b7ea8a16a9f39c533e8f",
    "test_1.csv": "85fb712c60c8137ce6e64b051afb80ba053fcf829ba4ab8375681b95e6cb8851"
  },
  "summaryChecks": {
    "samples": 40,
    "variants_per_sample": 3,
    "numeric_fields_checked": 1806,
    "csv_report_max_error": 0
  },
  "extractionChecks": {
    "original_numeric_values_checked": 476,
    "max_original_reproduction_error": 0,
    "pilot_feature_values_repeated_exactly": 252,
    "pilot_masks_repeated_identically": 18,
    "source_snapshot_hashes_verified": 4,
    "saved_masks": 102,
    "saved_sample_figures": 34,
    "analytical_unit_tests_passed": 4
  },
  "repeatedTestChecks": {
    "previous_run": "/Users/michaeldunn/bio-hack/experiments/multichannel_segmentation_v1/results",
    "current_run": "/Users/michaeldunn/bio-hack/experiments/multichannel_segmentation_v1/full_test",
    "samples": [
      "3e122cbj",
      "fn0mhxef",
      "xrv9xvzb"
    ],
    "numerical_values_compared": 126,
    "numeric_differences": [],
    "mask_sha256_matches": {
      "3e122cbj_original_bse_otsu.png": true,
      "3e122cbj_bse_kmeans_control.png": true,
      "3e122cbj_stacked_three_channel.png": true,
      "fn0mhxef_original_bse_otsu.png": true,
      "fn0mhxef_bse_kmeans_control.png": true,
      "fn0mhxef_stacked_three_channel.png": true,
      "xrv9xvzb_original_bse_otsu.png": true,
      "xrv9xvzb_bse_kmeans_control.png": true,
      "xrv9xvzb_stacked_three_channel.png": true
    },
    "figure_sha256_matches": {
      "3e122cbj.png": true,
      "fn0mhxef.png": true,
      "xrv9xvzb.png": true
    },
    "all_old_values_exact": true,
    "all_old_masks_exact": true,
    "new_samples_without_previous_exports": [
      "0eryguqq",
      "4hq27w4c",
      "fhwrjtet",
      "fspqbkxl",
      "soo2ax3r",
      "y59rxmxl"
    ]
  }
}
Downloads and repository

Current three-detector analysis: 31 known images, nine test images and 14 saved measurement fields per image.

Calculation code and run records

Project repository

References

The glossary states which physical interpretation each source supports and where our implementation differs.

Electrode studies and measurement sources (21)
  1. Moon et al. (2021). Interplay between electrochemical reactions and mechanical responses in silicon–graphite anodes and its impact on degradation. Nature Communications 12, 2714.

    Silicon particle size, expansion and composite degradation; does not validate our segmented boundaries.

  2. Otero et al. (2018). Design-Considerations regarding Silicon/Graphite and Tin/Graphite Composite Electrodes for Lithium-Ion Batteries. Scientific Reports 8, 15851.

    Analytical model of initial porosity and expansion tolerance; does not validate a pore-chord threshold.

  3. Cabello et al. (2020). Towards a High-Power Si@graphite Anode for Lithium Ion Batteries through a Wet Ball Milling Process. Molecules 25, 2494.

    Processing-dependent silicon agglomeration and distribution; does not calibrate our spatial-variation statistic.

  4. Kench, Squires, Dahari and Cooper (2022). MicroLib: A library of 3D microstructures generated from 2D micrographs using SliceGAN. Scientific Data 9, 645.

    Compares phase fraction, surface-area density and two-point correlation to assess generated microstructures. It does not prescribe our Holm correction or a batch-assignment rule.

  5. Yen, Leber and Pibida (2020). Comparing Instruments. NIST Technical Note 2106.

    Equivalence requires a predefined range of practically acceptable differences. This statistical guidance does not supply electrode acceptance limits.

  6. SciPy. Independent two-sample t-test. Statistical software documentation.

    Welch's unequal-variance test and confidence interval for the difference in means.

  7. R Core Team. Adjust P-values for Multiple Comparisons. stats documentation; Holm (1979).

    Holm adjustment controls family-wise error while allowing dependence between valid tests.

  8. American Statistical Association (2016). Statement on Statistical Significance and P-Values. Interpretation guidance.

    A p-value is not an effect size or a probability of a manufacturing defect. Decisions require scientific context.

  9. Zielke et al. (2015). Three-Phase Multiscale Modeling of a LiCoO₂ Cathode: Combining the Advantages of FIB-SEM Imaging and X-Ray Tomography. Advanced Energy Materials 5, 1401612.

    Physical roles of electrode phases; does not validate our phase segmentation.

  10. Ebner et al. (2014). Tortuosity Anisotropy in Lithium-Ion Battery Electrodes. Advanced Energy Materials 4, 1301278.

    Particle morphology and directional transport; does not equate 2D chords with tortuosity.

  11. Müller et al. (2018). Quantifying Inhomogeneity of Lithium Ion Battery Electrodes and Its Influence on Electrochemical Performance. Journal of The Electrochemical Society 165, A339-A344.

    Microstructural heterogeneity in graphite anodes; does not validate a threshold for our silicon particle statistic.

  12. Dahari et al. (2025). Prediction of Microstructural Representativity From A Single Image. Advanced Science.

    Porosity representativity and correlation-based uncertainty, conditional on correct segmentation.

  13. Mitsch et al. (2014). Preparation and Characterization of Li-Ion Graphite Anodes Using Synchrotron Tomography. Materials 7.

    Ageing changes in tortuosity and surface area despite similar porosity; not a manufacturing-batch validation.

  14. Choi et al. (2023). Optimization of Pore Characteristics of Graphite-Based Anode for Li-Ion Batteries by Control of the Particle Size Distribution. Materials 16, 6896.

    Graphite particle mixtures alter packing and porosity. Our silicon particle sizes are not identified graphite particle sizes.

  15. Taiwo et al. (2016). Comparison of three-dimensional analysis and stereological techniques for quantifying lithium-ion battery electrode microstructures. Journal of Microscopy.

    Scope and limitations of two-dimensional versus three-dimensional measurements.

  16. Polaron (2026). Quantifying and Optimising Solid-State Battery Electrodes. Industrial case study.

    Phase-specific interfaces, connectivity and transport from reconstructed solid-state electrodes. A different chemistry and measurement scope from this report.

  17. Kelly (2007). Some Aspects of Measurement Error in Linear Regression of Astronomical Data. The Astrophysical Journal 665, 1489-1506.

    Example of modelling measurement uncertainties separately from observed values; not an electrode study.

  18. scikit-image. Multi-Otsu thresholding. Software documentation.

    Intensity-based segmentation.

  19. scikit-learn. KMeans. Software documentation.

    Joint intensity clustering with explicit initial centroids. Algorithm documentation does not validate our material labels.

  20. scikit-image. Region measurements. Software documentation.

    Component area, equivalent diameter and ellipse axes.

  21. NumPy. Standard deviation. Software documentation.

    Population standard deviation used for spatial variation.