Code readingHow-to

Contrast stretch with percentile limits: replicate imtool in code

Replicate the imtool 2% eliminate-outliers contrast stretch in code: compute the 2nd and 98th percentiles of signed filter output, map to 0-1, convert last.

LumaSpec Vision12 min read Share on LinkedIn

On this page 10 sections
  1. 01Why the cracks vanish between filter and display
  2. 02What Eliminate outliers actually computes
  3. 03The percentile stretch in six steps
  4. 04Worked example: percentile limits for a 200 x 200 Sobel crack crop
  5. 05Keep the conversion last, not first
  6. 06Tune the percentiles to the crack coverage
  7. 07Check the result before the pipeline consumes it
  8. 08When the percentile stretch is the wrong tool
  9. 09Where signed height maps come from
  10. 10Questions

Sobel-filter a height map and the cracks you are after come out negative: the intact surface sits near zero, ridges go slightly positive, and every display shortcut in sight quietly deletes the negative half. The viewer's contrast tool makes the cracks obvious with its Eliminate-outliers setting at 2%, but the same step has to run in code before the rest of the pipeline can use the image. This guide gives you those lines: compute the 2nd and 98th percentiles of the signed filter output, map that range linearly to [0, 1], clip, and convert to 8 bits only afterwards. The same contrast stretch works for any signed filter response - gradient, difference of Gaussians, matched filter - and for signed depth frames from 3D sensors.

Why the cracks vanish between filter and display

The failure is not in the filter; it is in the trip from signed data to the screen. A Sobel kernel applied to a height map returns the height difference between neighbouring pixels, so gs_filt is signed and carries the sensor's distance unit: negative millimetres where the surface dips into a crack, near zero on the flat field, positive on burrs and ridge artifacts. Every default display path then assumes the data already sits in [0, 1], and the negative half pays for it.

What you run on gs_filt What comes back Why the cracks drop out
imshow(gs_filt) almost only black double images display over [0, 1]; values near 0 render black
stretchlim(gs_filt, [0.01 0.99]) low = 0, high positive returned limits live inside the display range, and the negative tail is thinner than the 1% trim
mat2gray(gs_filt) cracks fused into the background maps the data's own minimum to 0; a few large positives stretch the scale, the crack band compresses toward 0
rescale or imadjust with a low limit of 0 negatives clamped to black the map clamps everything below its low limit
percentile map (this guide) dark crack lines on a mid-gray field the limits span the signed data itself

None of these steps is broken; each maps signed data through a convention built for images that already live in [0, 1]. The fix is to hand the display a range that actually spans the data, and percentiles are how the viewer does it. The clamping also corrupts numbers, not just pictures: negative signed values pushed into 8-bit data skew even simple noise statistics, as the SNR calculation guide for 8-bit camera images shows.

What Eliminate outliers actually computes

The imtool Adjust Contrast window, driven in code by imcontrast, defines its outlier elimination plainly: discard the lowest 2% and the highest 2% of pixel values, then rescale so the cut points become black and white - that is the 2% rule the tool's documentation states. For a signed image x:

lo = value of x with 2% of pixels below it (2nd percentile) hi = value of x with 2% of pixels above it (98th percentile) y = (x - lo) / (hi - lo), then clip y to [0, 1]

The map has exactly the shape of imadjust(lo, hi); the difference is where the limits come from. They are percentiles of the signed data, not endpoints of the display range, so the mapping is centred on where the data actually sits. Values below lo and above hi clip to the rails - that is the outlier elimination - and the 2% per end is a fixed choice, not a property of the image. The tuning section below shows when to move it.

The percentile stretch in six steps

Everything stays in double precision until the last step. The names come from a working Sobel-on-height-map session so you can map them straight onto your code: the height matrix is m_AofI, loaded from 'm_AofI.mat', and the filtered result is gs_filt.

  1. Filter in double. Run gs_filt = filter2(fspecial('sobel'), m_AofI, 'valid') on the raw height map. No cast, no absolute value: the sign is the crack signal.
  2. Flatten and sort. Take the N pixel values of gs_filt (N = rows x columns) and sort them ascending into a column v, with rank 1 the most negative value.
  3. Read the two limits. lo = v at rank ceil(0.02 x N), hi = v at rank ceil(0.98 x N). Rank conventions differ between environments by one rounding step at most, which moves each limit by one neighbouring sorted value.
  4. Map. Compute y = (gs_filt - lo) / (hi - lo) for every pixel.
  5. Clip. Set anything below 0 to 0 and anything above 1 to 1.
  6. Convert last. y now lives in [0, 1], so a double display shows it correctly. If the pipeline needs 8 bits, build y8 = round(255 x y).

As generic pseudocode, using your environment's sort and percentile calls:

data = gs_filt(:)              % N signed values, double
v    = sort(data)              % ascending
lo   = v(ceil(0.02 * N))       % 2nd percentile
hi   = v(ceil(0.98 * N))       % 98th percentile
y    = (gs_filt - lo)/(hi - lo)
y    = min(max(y, 0), 1)       % clip
y8   = round(255 * y)          % only now, if 8-bit is needed

This is the whole answer to doing the contrast adjustment in code: steps 2 and 3 produce the limits that stretchlim could not, and steps 4 to 6 are the rescale the viewer performs.

Worked example: percentile limits for a 200 x 200 Sobel crack crop

Inputs, and where each comes from:

  • Crop: 200 x 200 pixels, so N = 40,000. The crop size is an example assumption; the rank arithmetic scales to any N.
  • Units: millimetres. gs_filt holds height differences between neighbouring pixels, so it inherits the height map's unit. The values below are typical Sobel responses on a flat machined surface, assumed for the example; the sign convention - cracks negative, surface near zero - is the one the session reports.
  • Distribution: 700 pixels (1.75%) below -0.020 mm (crack cores and stray outliers), the bulk between -0.020 and +0.015 mm, and 800 pixels (2.0%) above +0.015 mm (ridge artifacts).

Rule in words: the low limit is the value at rank ceil(0.02 x N); the high limit is the value at rank ceil(0.98 x N).

Arithmetic:

  • Low rank: 0.02 x 40,000 = 800. The 800th smallest value sits just above the 700-pixel core group: lo = -0.018 mm.
  • High rank: 0.98 x 40,000 = 39,200. With 800 pixels above +0.015 mm, the 39,200th value is hi = +0.015 mm.
  • Range: hi - lo = 0.015 - (-0.018) = 0.033 mm.

Mapping three representative pixels with y = (x - lo) / 0.033:

  • Surface at 0 mm: y = 0.018 / 0.033 = 0.545, then 0.545 x 255 = 139 - mid-gray field.
  • Crack flank at -0.010 mm: y = 0.008 / 0.033 = 0.242, then 0.242 x 255 = 62 - dark line.
  • Deepest core at -0.020 mm: y = -0.002 / 0.033 = -0.061, clipped to 0 - black.

Result: cracks render as dark gray lines about 77 gray levels below the surface (139 - 62), and the deepest 2% of values saturate at the rails, which is the outlier elimination doing its job.

Decision: for locating cracks, keep 2/98; the lines are high-contrast and need no tuning. To measure the depth profile along a crack, move the low rank below the crack pixel count: 700 of 40,000 pixels is 1.75%, so the 1st percentile (rank 400, lo near -0.022 mm) places the whole crack band above the clip and preserves its shading.

Keep the conversion last, not first

The order filter, percentile limits, map, clip, convert is the whole trick in one line. Any unsigned cast earlier in that chain deletes the crack signal before the percentiles ever see it: a uint8 or U16 cast clamps every negative pixel to 0, the low percentile becomes 0 forever, and the stretch then "works" on data that no longer contains cracks.

Signed 16-bit camera frames need the same care on the way in: cast with the sign preserved first, as the I16 to U16 conversion guide walks through, and depth frames formatted 16UC1 have their own display-side conversion, covered in the 16UC1 depth display guide. On the way out, the same trap: a float image written straight to JPEG can come out black because the encoder expects the 0-255 range, which the float-to-JPEG scaling guide diagnoses. Stretch, clip, then encode.

Tune the percentiles to the crack coverage

The 2/98 pairing is the tool's default and the right starting point. Two adjustments cover the rest:

  • Cracks that fill more than 2% of the image. The 2nd percentile then falls inside the crack population and the deepest cores clip to solid black. If the shading inside the crack matters, set the low percentile below the crack's area fraction: a crack covering 5% of the pixels calls for the 1st percentile or lower. If it does not matter, leave 2/98 - solid dark lines are the easiest to segment.
  • Bright ridges eating the top of the scale. Ridge and burr artifacts are usually sparser than crack pixels but brighter. Lower the high percentile to the 95th to push them onto the white rail and hand the mid-range back to the surface.

What the limits must not do is move between frames in a measurement chain. Percentiles are content statistics: two images of the same surface give different limits, so a gray level of 0.2 is not a physical depth. Fix decision thresholds on the signed data instead - the threshold basics guide without a vision library shows the fixed-level approach, and combining results across frames, rather than stretching each frame harder, is what the multi-frame edge combination guide is for.

Check the result before the pipeline consumes it

Five computable checks catch every wrong turn:

  • lo is negative and hi is positive. On signed Sobel output the limits must straddle zero; lo >= 0 means the negative tail was destroyed upstream by an early cast.
  • The clipped fraction matches the trim. Count y equal to exactly 0 and exactly 1: each should be about 2% of N - 800 pixels per rail in the 40,000-pixel example. Far more means the limits landed inside the crack band or the data was already clipped.
  • The surface lands mid-gray. The bulk of pixels near 0 map near (0 - lo) / (hi - lo), about 0.54, or 139 in the example. A dark overall image means lo sits far from the data.
  • Cracks read as connected dark lines at least 50 gray levels below the field, not speckle. Speckle means noise is winning; the crack segmentation guide picks up from there.
  • The histogram of y spans [0, 1] instead of piling up against one rail.

When the percentile stretch is the wrong tool

Skip the stretch entirely when a number comes out at the end. Crack width, crack depth and pass/fail decisions need the signed data and a threshold that stays put between images: measure the mean and standard deviation of a known-flat strip of the map, set the decision level at 4 standard deviations below the mean, adjust it against a known crack before production, then count or measure pixels below it on gs_filt. The stretch remains a display and export step: monotonic within one image, never comparable across two. And when one frame is too noisy to decide from, filtering results across frames beats stretching any single frame harder - the two do not mix, because percentile limits rescale the noise along with the signal.

Where signed height maps come from

Nothing above is specific to one sensor: the input is any height map, one height value per pixel, in a distance unit. Inline surface-crack inspection builds that map line by line. A line-scan 2.5D inspection system such as the MV-HLVS-16K100-L60 covers a 600 mm emitting area at 16K resolution with a 120 kHz sampling rate, one height profile per trigger, cracks included.

3D sensorsHikrobot MV-HLVS-16K100-L60 Line-scan 2.5D, 16K resolution, Sampling rate 120 kHz, Emitting area length 600 mmMV-HLVS-16K100-L60
Type
2.5D inspection
Price on requestView and quote

For a smaller strip, a single seam or a robot-guided scan head, a laser profile sensor such as the MV-DP4180-03P or the MIS-DP3850-01P captures the same kind of profile height map, and the filtered-crack pipeline above runs on it unchanged.

3D sensorsHikrobot MV-DP4180-03P 3D Laser Profile SensorMV-DP4180-03P
Type
Laser profiler
$12,389.00 · quotedView and quote
3D sensorsHikrobot MIS-DP3850-01P 3D laser profile sensorMIS-DP3850-01P
Type
Laser profiler
$12,379.00 · quotedView and quote

The cameras that pair with these sensors sit in the area scan cameras category, and the camera finder filters both groups by resolution, interface and shutter. If you are building the station rather than the script, the system configurator assembles the chain from camera to trigger, or ask us directly through the contact page. More processing guides like this one live on the code-reading topic page.

Questions

Why does stretchlim return a low limit of 0 on my signed filter output?

stretchlim returns limits inside the display range of the data class, and for double images that range is [0, 1]. A negative low limit is never returned, so on signed Sobel output the low limit collapses to 0 whenever the negative tail is thinner than the trim fraction - the default trim is 1% per end. imadjust then clamps every negative pixel to black together with the background, which is exactly the disappearing-crack effect.

Which percentiles should I use when the crack covers more than 2% of the image?

Drop the low percentile below the crack's area fraction. A crack filling about 5% of the pixels sits partly below the 2nd percentile, so its deepest cores clip to solid black; the 1st or 0.5th percentile moves the limit past the crack band and keeps the depth gradient inside it. Leave the high percentile near 98 unless bright ridge artifacts are eating the top of the scale.

Can I run this stretch on 16-bit signed camera frames?

Yes - compute the percentiles on the raw signed values and map to [0, 1] or to your target range; never clamp negatives to 0 first. Signed 16-bit camera data has to be cast with the sign preserved before any unsigned step, which is the same problem the I16-to-U16 conversion guide solves. Depth frames formatted 16UC1 need the same treatment on the display side.

Should I threshold the stretched image or the signed data?

Threshold the signed data. Percentile limits move from frame to frame, so a gray level of 0.2 corresponds to a different physical depth in every image, and a threshold set on stretched output drifts with the content. Fix the decision level once - background mean minus a few noise standard deviations, measured on a known-flat strip - and apply it to the filtered matrix; keep the stretch for display and export only.

Why does the exported image look black when the on-screen view looked right?

The viewer scales for display; the file keeps raw values. A double image written straight to an 8-bit format clamps negatives to 0, and a float written to JPEG can come out black because the encoder expects the 0-255 range. Run the percentile stretch first, then scale to 0-255, round, and encode - the export guide on float images shows the failure mode and the fix.