Technical Whitepaper
What the processing pipeline actually does, end to end: the image-processing pipeline, algorithms, thresholds and formulas behind every number the app shows. This is a technical reference, not a how-to-use-the-app guide — for that, see the Manual. Written directly from the pipeline source (core_pipeline_v2/) and the metrics module (service/backend/app/geotech.py); kept in sync with the code, not the other way around. The specific AI model(s) used for image segmentation, and how they are directed, are proprietary and not detailed here — this document covers the surrounding pipeline, geometry, thresholds and metrics built around that component.
Pipeline overview
Every core-box photo runs through six steps, in order, each reading the previous step's output from disk and writing its own artifacts (a processed image/mask and a JSON record) for the next step and for the web app to read. Running a later step automatically runs any earlier step whose artifacts are missing, so the chain is always complete.
| 1 | Wood/box subtraction — remove the wooden tray from the photo |
| 2 | Box segmentation & depth calibration — find the core channels, calibrate pixels→metres, merge into one depth-true strip |
| 3 | Union map & fracture candidates — segment rock vs. not-rock and locate candidate breaks |
| 4 | Fracture confirmation — turn candidates into geometrically-measured, confidence-scored fracture markers with real depths |
| 5 | Rubble & missing-core zone detection — classical computer vision, on steps 2–3's output |
| 6 | Rock color profile — median core color every 20 cm |
Steps 1, 3 and 4 use an AI-based image segmentation component to identify wood/tray regions, rock material, and fracture-like features respectively — its internals are outside the scope of this document. Steps 2, 5 and 6 are classical image processing only, operating on the masks steps 1 and 3 already produced; nothing about their algorithms, thresholds or formulas is confidential, and they are described in full detail below.
Step 1 — Wood/box subtraction
Goal: remove the photographed wooden tray so every later step only ever looks at the rock. An AI-based detector identifies wood/tray pixels; every pixel it confidently calls wood is made transparent (alpha = 0) in the output image — nothing is cropped or painted over, just keyed out, so the original frame and its depth reference stay intact.
The guard: the wood mask is corrected against a second, independent detection pass for rock/stone material — any pixel confidently identified as rock/stone is subtracted back out of the wood mask (wood AND NOT rock), specifically to stop wood-colored core (common in half-core boxes) from being wrongly keyed out as tray.
Step 2 — Box segmentation & depth calibration
This step answers two questions: where are the individual core channels (rows) within the photo, and what is the pixel↔metre scale for this specific photo?
Finding channels: working inside the photographed box's outer boundary (from the wood mask's connected components), rows that are mostly non-wood are grouped into bands — one band per channel. A fallback pass instead looks for a wide contiguous non-wood run per row, for short "stub" boxes where the primary method finds nothing. The inner opening width common to all channel rows (the region nearest the photo's horizontal center) is taken as the box's true interior width in pixels.
Calibration — how depth-per-pixel is actually determined: the system does not measure a printed ruler or the core diameter. It assumes, by convention, that the box's inner opening width in pixels corresponds to exactly one channel_length_m (default 1 m, configurable per drillhole) of drilled core, and derives scale directly from that:
px_per_m = inner_width_px / channel_length_m mm_per_px = channel_length_m × 1000 / inner_width_px
Scale confidence is high when the detected inner width covers at least 82% of the box's own bounding-box width (i.e. a channel plausibly spans the box's full interior), else low. When confidence is low, exactly one channel was used, and the box's declared depth interval is shorter than channel_length_m, the scale is rescaled to fit that shorter interval and re-flagged corrected_by_interval.
Detected channels are snapped onto a regular pitch (median inter-channel spacing), with any channel the detector missed synthesized geometrically at its expected slot rather than left out — so a single bad detection doesn't shift every depth below it. Channels are then cropped and concatenated left-to-right into one continuous, depth-true merged strip with a ruler footer, which is what every other step and the web app render.
Step 3 — Union map & fracture candidates
This step produces three independent masks per photo, over the box-interior region only:
- Union / material mask — the combined output of the rock/material detector: "is this pixel rock at all."
- Cracks mask — the fracture-detection component's direct, semantic guess at where breaks are.
- Edges mask ("multi-mask diff") — a second, independent detection channel that does not come from the fracture detector at all. It is built from internal inconsistencies in the material detector's own output (a classical boundary/gradient analysis, with the material mask's outer silhouette removed so the core's visible outline isn't mistaken for a break, and long thin horizontal shapes rejected as core-silhouette artifacts, not fractures) — a seam shows up here even where the material mask reads as one continuous blob. This channel is acknowledged in the pipeline's own design notes as noisier and in more need of improvement than the primary crack detection.
No geometry (width, thickness, angle) is computed at this stage — only mask algebra and coverage bookkeeping. Geometric measurement is entirely step 4's job.
Step 4 — Fracture confirmation & geometry
The candidate pool for this step is the pixel-wise OR of step 3's cracks mask and edges mask, restricted to a generous margin around the material mask (to drop distant noise such as floor stains, while still catching fractures right at the rock's edge). Each connected blob in that pool is one candidate; blobs under 150 px or shorter than 15% of the channel's height are dropped immediately as insignificant specks.
For every remaining candidate, five values are measured directly from its pixel footprint:
| depth_m | the candidate's median along-core position, converted from pixels using the channel's own depth origin and the calibrated px/m. |
| width_mm | the candidate's bounding-box extent along the core axis (depth direction) — despite the name, this measures how far the break runs along the core, not the gap's true opening width. Used to reject wide, non-planar, fragmented blobs. |
| thickness_mm | pixel area divided by height — the mean opening width of the seam. This is the field that reflects an actual fracture aperture. |
| alpha_deg | the angle of the candidate's principal axis (via PCA on its pixel coordinates) relative to the core's long axis, folded to 0–90°. |
| coverage | the candidate's vertical extent (across the core's diameter) divided by the local core diameter at that depth — how much of the core's full cross-section the break appears to reach, capped at 1.5. |
Confidence is a rule, not a learned score: it starts from 1.0 if the candidate is supported by both detection channels (the cracks mask and the edges mask each covering >30% of its pixels), 0.8 if only the primary fracture-detection channel agrees, or 0.5 if only the weaker edge/mask-diff channel does — then scaled by a coverage-derived factor between 0.5 and 1.0. A sources field records which channel(s) contributed to each candidate.
A candidate is accepted as a confirmed fracture only if all of: coverage ≥ 0.60, angle between 20° and 90°, thickness ≤ 20 mm, along-core extent ≤ 120 mm, and confidence ≥ 0.45. Anything failing any one of these is kept as a rejected candidate (visible in the app when filters are set to show rejected items), not discarded — the raw detection and the accept/reject decision both stay on record.
Rubble/crushed core is deliberately not reported here. A crushed interval produces wide, fragmented candidates that fail the along-core-extent and/or angle/thickness rules by design, so they fall out as rejected candidates rather than a flood of spurious "fractures." That population is exactly what step 5's rubble-zone detector is built to characterize instead.
Confirmed fractures within 10 mm of each other (along the core axis, within the same channel) are merged into one, keeping whichever has higher confidence — a simple nearest-neighbor collapse, not a clustering algorithm, and it only ever compares each candidate to its immediate neighbor.
Step 5 — Rubble & missing-core zone detection
Unlike steps 1, 3 and 4, this one is pure classical computer vision over the masks steps 2–3 already produced — no AI-based detection at all. It looks for two geologically distinct conditions along the core: rubble (material is present but crushed into fragments, not a solid cylinder with discrete breaks) and missing core (no material recovered at all — the opposite condition).
Sliding window: a 100 mm window steps along the core in 20 mm increments (5× overlap). Per window, five features are computed against the union (rock) mask: the fraction of the window that has any rock at all (near-zero flags it as a possible no-recovery gap); fill — median material width divided by the reference core diameter (spread-wider-than-the-core is the strongest single signal); edge density — how much of the "multi-mask diff" edge mask falls inside the window's rock pixels (fragment outlines); shadow fraction — the share of rock pixels darker than 45% of the window's own median brightness (gaps between fragments cast shadows); and a texture/gradient measure used only to damp scores on smooth material (dust, weathered blocky core), not to raise them.
These combine into one score per window:
confined = min(edge_score, shadow_score) × confinement_gate × fill_elevation_gate edge_alone = edge_alone_score × fill_elevation_gate score = max(fill_score, confined, edge_alone) × (0.5 + 0.5 × texture_score) × coverage_gate
Rationale: material spread wider than one core diameter is rubble on its own (fill_score); rubble confined to a narrow channel doesn't spread outward, so it instead needs both edges and shadows together (a vein alone produces edges but no shadow) — gated so it can't fire on near-empty windows or on ordinary color/oxidation transitions in otherwise intact core (a fill-ratio "elevation" gate, calibrated against real false positives that clustered at fill ratios of 0.97–1.02 vs. genuine rubble at 1.15–1.26); and overwhelming fragment-edge density alone is treated as rubble even with a weak shadow signal, a threshold calibrated across roughly 1,500 boxes and 337,000+ windows.
Reference core diameter: measured once per drillhole, not per box. Among that drillhole's boxes with at least 50% rock coverage, the one with the least combined fracture-candidate and edge-mask coverage is treated as the cleanest, most intact cylinder, and its median rock-column height becomes the drillhole's diameter reference (cached, reused for every box in that hole). If no such reference exists yet (e.g. a single box with nothing else to compare against), a weaker per-box fallback (25th percentile of that box's own column heights) is used instead.
Zone boundaries — hysteresis and trimming: because each window is 100 mm wide but steps only 20 mm, any point along the core is covered by up to five overlapping windows. Zone detection uses the smoothed maximum score across those overlaps (so one internal dip doesn't fragment a real zone), while zone boundaries are independently trimmed inward using a stricter, non-overlapping view of the same windows — otherwise a 100 mm-wide window straddling the true rubble/solid transition would credit the entire window to the zone, smearing the reported boundary up to 100 mm into solid core. Zones separated by 60 mm or less are merged into one; zones under 100 mm after trimming are dropped entirely (matching the RQD intact-piece threshold). A zone whose windows show negligible fragment-edge density is vetoed regardless of its other scores, on the reasoning that edge density essentially never misses real rubble — vetoed candidates are kept visible in the QC image rather than silently discarded.
Missing-core zones reuse the same window/hysteresis/merge/trim machinery, applied instead to windows already flagged near-empty of rock; any span already claimed by a rubble zone is explicitly excluded first, since a stretch of core cannot be both crushed and absent.
Step 6 — Rock color profile
For every 20 cm of core, the median color (BGR channel-wise median, not mean) is computed over only the pixels the union/material mask identifies as rock — wood, tray and background are already excluded by that mask. Pure-white pixels are separately dropped first, to catch glare, overexposed highlights and mask-edge bleed the material mask alone doesn't catch. A bin is skipped if less than 10% of its width has any rock at all.
These are raw camera colors with no white-balance or lighting correction applied — comparable within one photo, only approximately comparable across boxes photographed under different lighting. This is an explicit, acknowledged limitation, not an oversight; a normalization pass could be layered on later without changing the output shape.
Geotechnical metrics: RQD, FF, TCR
Computed downstream of the pipeline, from stored fractures and rubble/missing zones — not part of the image-processing steps themselves, but the numbers those steps ultimately feed.
RQD (Rock Quality Designation, Deere 1963, fracture-spacing method): the drill is split into pieces at every confirmed fracture depth (fractures inside a rubble or missing-core zone are excluded from this — see below). Any part of a piece that overlaps a rubble or missing-core zone is removed from consideration; what's left counts as intact only if that remaining fragment is itself longer than 100 mm.
RQD (%) = (intact length within the interval) / (interval length) × 100
Classes: ≥90% Excellent, ≥75% Good, ≥50% Fair, ≥25% Poor, else Very Poor. A reliability flag is set to "low" on any interval where rejected fracture candidates outnumber confirmed ones by 3× or more — a signal that a crushed section is producing a conservative fracture-spacing estimate that likely overstates quality there.
FF (fracture frequency, fractures per metre) counts confirmed fractures directly, except inside rubble or missing-core zones: a fracture nominally detected inside either is geological noise, not a real discrete break, so it is excluded from every count and display — but the zone doesn't simply contribute zero. Geologically, both crushed core and a no-recovery gap are treated as carrying a fixed assumed density of 4 fractures per 10 cm, and that fixed rate is what feeds FF (and only FF — displayed fracture counts always stay literal, real-detections-only numbers). ISRM spacing classification (Very widely spaced through Very closely spaced) is derived from the whole-drill FF value using standard ISRM bands.
TCR (Total Core Recovery): covered length minus missing-core length, divided by covered length. Rubble counts as recovered here — it's present, just broken — so only true no-recovery gaps reduce TCR, unlike RQD, which excludes both.
The same three metrics are also computed as a sliding 1 m window stepping every 10 cm (ten times the depth resolution of the 1 m-bin view), for the profile curves shown alongside the binned bars — identical formulas, just evaluated far more densely along depth.
Honest caveats
Known limitations and deliberate simplifications, stated plainly. This list will grow as more are identified.
- Depth calibration is a convention, not a physical measurement. Step 2 assumes the box's photographed inner width equals exactly one channel-length of core; it does not read a printed ruler, marker, or the core's own diameter. If a box is photographed with the wrong assumed channel length, or the inner opening is misdetected, every depth in that box shifts accordingly (mitigated, not eliminated, by the low-confidence rescue path and by
scale_confidenceflags visible in the box report). - Rubble-zone reference diameter is a heuristic proxy, not a direct measurement. The "most solid" box is chosen as the one with the least fracture-candidate and edge-mask coverage among boxes meeting a rock-coverage floor — a proxy for "intact cylinder," not a caliper reading, and it depends on step 3's mask quality. The per-box fallback used when no drill-wide reference exists yet is explicitly weaker (a box that is mostly rubble can fool it).
- RQD's "low reliability" flag is a soft signal, not a correction. A crushed interval that produces many rejected candidates gets flagged, but the RQD number itself is not recalculated or adjusted differently because of it.
- RQD is fracture-spacing based, not core-recovery corrected. A section with missing core reads better than it geologically is; a recovery-corrected variant is a possible future addition, not yet implemented.
- Automatic re-detection is not fully backward-compatible with prior human review. When a detection algorithm changes (this has happened at least twice for rubble/missing-zone boundaries), re-running it can shrink, merge, or drop zones a person already reviewed and confirmed — including causing a previously-confirmed zone to disappear entirely if it no longer clears a minimum-length floor after the fix. This is surfaced for manual review, not silently discarded or force-kept, but it means confirmed status is not permanently pinned against pipeline changes.
- The "multi-mask diff" fracture-detection channel is acknowledged in the pipeline's own design notes as the weaker of the two detection sources — noisier than the primary AI-based crack detection, and flagged by the original author as needing further improvement, though it remains part of the shipped pipeline as a secondary signal.
- Fracture merging only looks at immediate neighbors along one channel. Two genuinely distinct fractures a little over 10 mm apart are never merged, and three or more fractures packed within a tight cluster can still collapse to fewer markers than truly exist, since the merge only ever compares consecutive pairs.
- A drillhole's last box, when it holds a short, partial interval of core rather than a full channel length, can be detected incorrectly. Box segmentation and depth calibration are tuned around typical full-length boxes; a shorter trailing interval is a known edge case where channel geometry or scale can come out wrong.
- Rubble-zone detection can still be improved for loose material. Sand, dirt, and gravel in the photographed box can visually resemble crushed core and are not yet reliably distinguished from genuine rubble.
- The fracture candidate-to-confirmation rule set has room for improvement. The coverage/angle/thickness/extent/confidence thresholds that decide whether a candidate becomes a confirmed fracture (step 4) are hand-tuned heuristics, not a learned or exhaustively validated classifier.