A frame with up to 255 gray levels comes in, and the task reads: pixels from 170 up come out black, everything below comes out white. The contrast-stretch program you found compiles, runs and then paints most of the image black. Two defects cause it: the slopes are computed with integer division, and the job itself is a threshold, not a stretch. This how-to fixes the arithmetic for the day you truly need the ramp, then replaces the whole triple loop with one cv::threshold call, adds a probe test that proves the mapping, and closes with the checks that keep a fixed level honest on a live line.
Why does the contrast-stretch loop return a black image?
Because s1 / r1 and (255 - s2) / (255 - r2) divide two int values, C++ truncates each quotient before the float multiply ever runs; with s1 = 0, r1 = 50, s2 = 255 and r2 = 150 the three slopes compute to 0, 2 and 0. Declaring result as float does not rescue the arithmetic: C++ evaluates s1 / r1 first, both operands are int, so the fraction is discarded, the multiply by the integer pixel value also lands as int, and the assignment to float happens after the damage. The mapping the code intends is:
Trace the inputs r1 = 50, s1 = 0, r2 = 150, s2 = 255 through the code as written:
-
s1 / r1= 0 / 50 = 0: every pixel from 0 to 50 maps to 0 × x = 0. That is the black mass in the output. -
(s2 - s1) / (r2 - r1)= 255 / 100 = 2 (2.55 truncated). The brightest mid-range pixel, x = 150, returns 2 × (150 - 50) + 0 = 200, which is 55 levels short of 255. -
(255 - s2) / (255 - r2)= 0 / 105 = 0: every pixel above 150 returns 0 × (x - 150) + 255 = 255 — white, but by accident rather than by the stretch.
The repair, if you ever need this ramp, is to force the division into float: (float)s1 / r1 * x evaluates 0.0f / 50 = 0.0f, and (float)(s2 - s1) / (r2 - r1) evaluates 255.0f / 100 = 2.55, so x = 150 gives 2.55 × 100 = 255 exactly. saturate_cast<uchar> then clamps each result into 0-255.
Is the task a contrast stretch or a threshold?
It is a threshold, not a stretch: your specification — black from 170, white below 170 — is a two-value step function, so the four console inputs, the computeOutput helper and the triple loop all collapse into one cv::threshold call on a grayscale Mat. A stretch keeps every gray level and moves them apart; run to the end, it would leave the pixels between r1 and r2 as a spread of mid-grays, so a "black" part shows up dark gray and a contour or blob operator sees a third class between the two you asked for. cv::threshold computes the step in optimized code: no loop, no at<Vec3b> access per pixel per channel, no prompts to answer on every run. It is the same step function that drives basic thresholding in edge detection without a vision library, so the skill carries over.
Which cv::threshold flag makes pixels from 170 black?
Use THRESH_BINARY_INV with the threshold value 169 on a grayscale Mat: OpenCV sets each output pixel to 0 when the source exceeds the threshold and to maxval otherwise, so every pixel from 170 up comes out black and everything below comes out white. The comparison in both flags is strictly "greater than": a source pixel of exactly 170 fails src > 170 and would land white, which contradicts a spec that counts 170 as black. Passing 169 puts the boundary where the spec reads it.
| Flag | Output rule | thresh 169, maxval 255 |
|---|---|---|
THRESH_BINARY |
maxval if src > thresh, else 0 | 170 becomes white, 169 black |
THRESH_BINARY_INV |
0 if src > thresh, else maxval | 170 becomes black, 169 white |
maxval is the value assigned to the below-threshold class; keep it at 255. The OpenCV imgproc API documentation states both rules and the strict comparison. This polarity — the dark class winning the top of the range — is exactly the mix-up that the guide on threshold polarity before skeletonizing line patterns untangles; if the spec ever flips to "from 170 white", swap the flag rather than inverting the image afterwards.
Does cv::threshold need a grayscale Mat first?
Yes: cv::threshold expects a single-channel 8-bit or 32-bit float source, and imread("pic.jpg") returns three BGR channels even when the JPG looks gray, so read with IMREAD_GRAYSCALE or convert with cvtColor and COLOR_BGR2GRAY before the call. The default imread flag colors every file, so a threshold call on a fresh 3-channel Mat fails its single-channel assertion at runtime rather than returning something subtle. IMREAD_GRAYSCALE collapses the channels on load; for frames already in memory, cvtColor(src, gray, COLOR_BGR2GRAY) applies the weights 0.299 to red, 0.587 to green and 0.114 to blue.
The weights explain why a color pixel lands where it does on the boundary. Take B = 100, G = 140, R = 255: gray = 0.114 × 100 + 0.587 × 140 + 0.299 × 255 = 11.4 + 82.18 + 76.245 = 169.825, which rounds to 170 — a strongly blue pixel sits exactly on the decision level. Thresholding the three channels separately, as the original loop does with its pass over B, G and R, produces three independent binary planes and colored fringes at every edge; if you need per-channel decisions, make them deliberately, otherwise work in gray. The same channel-count trap shows from the display side in the Mat type and pixel format mismatch guide, and it pays to verify the pixel format before processing.
What does the working program look like?
Four lines: read the frame as 8-bit single channel, threshold at 169 with maxval 255 and THRESH_BINARY_INV, count the classes with countNonZero, and write the single-channel result — the full program below compiles against an OpenCV 4 install.
-
Read single-channel.
cv::Mat src = cv::imread("pic.jpg", cv::IMREAD_GRAYSCALE);then testsrc.empty()— a wrong path returns an empty Mat, not an exception. -
Threshold.
cv::threshold(src, binary, 169, 255, cv::THRESH_BINARY_INV);— one call, no loop. -
Count the classes.
int white = cv::countNonZero(binary);thenint black = static_cast<int>(binary.total()) - white;. -
Write a single-channel PNG.
cv::imwrite("binary.png", binary);
Assembled:
#include <opencv2/imgproc.hpp>
#include <opencv2/imgcodecs.hpp>
#include <iostream>
int main()
{
cv::Mat src = cv::imread("pic.jpg", cv::IMREAD_GRAYSCALE); // 8-bit, 1 channel
if (src.empty()) { std::cerr << "read failed"; return 1; }
cv::Mat binary; // 8-bit, 1 channel
cv::threshold(src, binary, 169, 255, cv::THRESH_BINARY_INV);
int white = cv::countNonZero(binary); // pixels 0-169
int black = static_cast<int>(binary.total()) - white; // pixels 170-255
std::cout << white << " white, " << black << " black pixels";
cv::imwrite("binary.png", binary);
return 0;
}
On a production line the frames arrive from a camera SDK rather than a file, and the four lines stay unchanged: wrap the SDK buffer once and the threshold never knows where the Mat came from — the guide on wrapping an SDK frame buffer in a cv::Mat without copying shows that step. Any of the GigE area scan cameras feeds this code unchanged; the MV-CU020-90GC is a 2.5 MP color model with a 1/1.7" sensor, a global shutter and a C-mount.
- Sensor format
- 1/1.7"
- Lens mount
- C-mount
- Interface
- GigE
- Resolution
- Up to 3 MP
How do you prove the binary output is correct?
Feed the call a synthetic 1 × 4 image holding the pixel values 169, 170, 171 and 255, then check the output is 255, 0, 0, 0 — four values that bracket the boundary prove the mapping for the whole image, because the operation acts per pixel.
cv::Mat probe = (cv::Mat_<uchar>(1, 4) << 169, 170, 171, 255);
cv::Mat out;
cv::threshold(probe, out, 169, 255, cv::THRESH_BINARY_INV);
std::cout << out << std::endl; // [255, 0, 0, 0]
| Probe pixel | probe > 169 | Output pixel |
|---|---|---|
| 169 | no | 255, white |
| 170 | yes | 0, black |
| 171 | yes | 0, black |
| 255 | yes | 0, black |
On a full frame the two class counts must sum to the pixel total: a 4,000 × 3,000 image holds 4,000 × 3,000 = 12,000,000 pixels, so white + black has to equal exactly 12,000,000 — anything else means the Mat was not single-channel 8-bit. A histogram cross-check works too: pixels in bins 0 through 169 must equal white. For storage write the result losslessly — saving a thresholded binary image without it opening black covers the write — and live binary preview for defect inspection puts the two-class image on screen while you tune.
When should you keep the manual pixel loop?
Keep a manual loop only when the mapping is not a step — a gamma curve, a live-updating ramp or a true stretch with intermediate grays — and then compute in float: cast one operand, as (float)(s2 - s1) / (r2 - r1), before it multiplies. Better, move any point mapping into a 256-entry lookup table built in float and apply it with cv::LUT(src, lut, dst): the divisions happen once at build time and the loop disappears.
A fixed level also assumes the images themselves stay put. Auto exposure and auto gain re-balance every frame, so the same part drifts across 169; set exposure and gain to manual values — the guide on live exposure and white balance control shows how — and drive the light steadily or strobe it from one of the light controllers so ambient light stops reaching the sensor.
When contrast, not code, is the real problem, fix the lighting before thresholding; when shading varies across the field of view and no single level can survive, adaptive thresholding for uneven part shading moves the decision locally.
If writing and maintaining this code is not your job, a smart camera applies exactly this kind of fixed threshold on the device — no PC and no SDK build. The LS-SMC-0063 pairs a 16 mm lens with a 1.6 MP sensor and runs 60 fps over GigE, and the smart cameras category holds the rest of the range.
- Interface
- GigE
- Resolution
- Up to 3 MP
For the task as stated — pixels from 170 black, below white — the decision is: grayscale Mat, threshold 169, maxval 255, THRESH_BINARY_INV. Reverse polarity takes the same 169 with THRESH_BINARY; a true ramp takes float-cast slopes or a LUT. To pick the camera and lighting that keep those levels honest, start at the camera finder or the configurator below.
The configurator matches a camera, lens, light, light controller and cables to each other, step by step, and lists only parts that fit what you already picked.
Questions
Why save the binary result as PNG rather than JPEG?
JPEG is lossy: it re-introduces intermediate gray values along every edge, so a clean 0/255 image gains gray fringes the moment you write it, and a later threshold or contour finder sees soft boundaries. PNG is lossless and stores the single 8-bit channel exactly, so black stays 0 and white stays 255.
How can OpenCV pick the threshold instead of a hard-coded 169?
Add the Otsu flag: call cv::threshold with thresh 0 and the type cv::THRESH_BINARY_INV | cv::THRESH_OTSU. OpenCV scans the 256-bin histogram and returns the level that minimizes within-class variance in the function's double return value, so a part with two clean peaks needs no manual number. Otsu runs on 8-bit sources only; keep the manual 169 when the histogram has one peak or a sloped background.
My specification is reversed — below 170 black, from 170 white — which flag now?
Keep the threshold value at 169 and swap to THRESH_BINARY. Its rule is the exact mirror: dst becomes maxval, 255, when src > 169 and 0 otherwise, so pixels from 170 up come out white and 169 and below come out black. No post-processing inversion is needed.
What changes when the camera delivers 12-bit or 16-bit frames?
The classic cv::threshold path accepts single-channel 8-bit or 32-bit float Mats, so convert or scale first: shifting 12-bit data right by four places maps 0-4095 onto 0-255, then re-tune the level because the noise floor scales with it. On float images pass the level as a float such as 169.0f, and remember Otsu stays 8-bit-only.
Why does the same part cross the threshold differently frame to frame?
Auto exposure and auto gain re-balance every frame, so pixel values move even under a steady scene and the fixed level catches different populations. Set exposure and gain to manual, confirm the histogram stops moving, then re-tune 169 on the locked image; a strobed light adds isolation from ambient light on top.

