Automated display defect detection using Python, OpenCV & Pytest
Detects dead pixels, color anomalies, and brightness variation in display screenshots.
Generates per-image PASS/FAIL reports with a quality score (0-100).
Dec 2025 - Feb 2026
- Overview
- Project Structure
- System Requirements
- Installation
- Quick Start
- Architecture
- Defect Categories
- Validation Pipeline
- Test Suite
- Sample Output
- Python API Reference
- HTML Report Generator
- Thresholds and Configuration
- Running with Coverage
- Troubleshooting
This tool automatically validates the visual quality of display screenshots by running three independent defect detectors and producing a unified report:
| Category | What it detects | Pass condition |
|---|---|---|
| Dead Pixels | Stuck near-black pixels via morphological analysis | <= 5 dead pixel clusters |
| Color Anomaly | R/G/B channel imbalance and saturation clipping | No channel deviates > 20% from mean |
| Brightness Variation | Luminance non-uniformity across a 3x3 zone grid | Std-dev < 40, variation < 30% |
Each image receives an overall PASS / FAIL verdict and a quality score from 0 to 100.
display-quality-validator/
|-- display_validator.py # Core engine: detectors + ValidationReport
|-- report_generator.py # HTML QA report builder
|-- conftest.py # Shared pytest fixtures (image factory)
|-- test_display_quality.py # 25 test cases across 3 defect categories
|-- requirements.txt # Pinned dependencies
|-- README.md # This file
|-- images/ # README diagram assets
|-- banner.png
|-- architecture.png
|-- defect_categories.png
|-- pipeline.png
|-- test_results.png
|-- sample_output.png
|-- folder_structure.png
| Requirement | Minimum | Recommended |
|---|---|---|
| Python | 3.9 | 3.11+ |
| OS | Windows 10 / Ubuntu 20.04 / macOS 11 | Ubuntu 22.04 / macOS 13+ |
| RAM | 512 MB | 2 GB |
| Disk | 200 MB | 500 MB |
| CPU | Any x64 | Multi-core for batch mode |
# Option A: clone (if using git)
git clone https://github.com/yourname/display-quality-validator.git
cd display-quality-validator
# Option B: unzip (if using the ZIP package)
unzip display-quality-validator.zip
cd display-quality-validator# Create venv
python -m venv venv
# Activate on Linux / macOS
source venv/bin/activate
# Activate on Windows (Command Prompt)
venv\Scripts\activate.bat
# Activate on Windows (PowerShell)
venv\Scripts\Activate.ps1pip install -r requirements.txtWhat gets installed:
opencv-python >= 4.8.0 # image loading, thresholding, contour detection
numpy >= 1.24.0 # array operations, luminance calculations
pytest >= 7.4.0 # test runner
pytest-cov >= 4.1.0 # code coverage reports
pytest-html >= 4.0.0 # HTML test result reports
Pillow >= 10.0.0 # optional: faster batch image I/O
python -c "import cv2, numpy; print('OpenCV:', cv2.__version__, '| NumPy:', numpy.__version__)"
pytest --versionExpected output:
OpenCV: 4.13.0 | NumPy: 2.4.2
pytest 7.4.x
python display_validator.py path/to/screenshot.pngpython display_validator.py path/to/screenshots/python report_generator.py path/to/screenshots/ qa_report.htmlThen open qa_report.html in any browser.
pytest test_display_quality.py -vpytest test_display_quality.py -v --html=test_report.html --self-contained-htmlThe tool is built around four main classes:
Converts the image to grayscale, applies a binary threshold (pixels <= 15 intensity = dead),
removes noise with morphological opening, then uses cv2.findContours to identify and count
stuck-pixel clusters. Returns a list of DeadPixelDefect objects, each with bounding box location
and severity rating.
Splits the BGR image into its three channels and computes the mean value of each. Any channel
whose mean deviates more than 20% from the overall image mean is flagged as a ColorDefect.
Also separately detects channel saturation clipping (more than 5% of pixels pegged at 255).
Converts the image to a float32 grayscale luminance map. Computes global standard deviation and then divides the image into a 3x3 zone grid, measuring the mean luminance of each zone. Calculates the percentage variation between the brightest and darkest zones. Fails if std > 40 or zone variation > 30%.
The orchestrator. Loads each image via cv2.imread, validates its dimensions, runs all three
detectors, aggregates results into a ValidationReport dataclass, and computes a quality score.
Supports single-image and batch modes, and can produce a JSON summary.
A dead pixel is a display cell stuck permanently at very low luminance (near-black). The detector uses this OpenCV pipeline:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_, mask = cv2.threshold(gray, 15, 255, cv2.THRESH_BINARY_INV)
kernel = np.ones((1, 1), np.uint8)
clean = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
contours, _ = cv2.findContours(clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)A CRITICAL defect is raised when the cluster area is greater than 4 pixels.
A WARNING is raised for isolated single pixels.
Channel imbalance is computed as:
deviation_pct = |channel_mean - overall_mean| / overall_mean x 100
If deviation_pct > 20% for any channel, a ColorDefect is raised.
If deviation_pct > 40%, severity is CRITICAL; otherwise WARNING.
The zone grid approach:
for r in range(3):
for c in range(3):
zone_mean = np.mean(luminance[r*H//3:(r+1)*H//3, c*W//3:(c+1)*W//3])
zone_means.append(zone_mean)
variation_pct = (max(zone_means) - min(zone_means)) / max(zone_means) * 100Fails if std > 40 or variation_pct > 30%.
score = 100.0
score -= min(dead_pixel_count * 5, 40) # max penalty: -40
score -= min(critical_color * 10, 30) # max penalty: -30
score -= min(critical_brightness * 10, 30) # max penalty: -30
score = max(0.0, score)| Score range | Interpretation |
|---|---|
| 90 - 100 | Excellent: display is fully functional |
| 70 - 89 | Good: minor anomalies detected |
| 50 - 69 | Poor: significant defects present |
| 0 - 49 | Critical: display requires attention |
The test suite contains 25 test cases across 4 test classes,
all using synthetically generated images from conftest.py.
No external image files are required.
The session-scoped factory generates 14 types of synthetic test images:
| Kind | Description |
|---|---|
clean_grey |
Uniform 128-grey 200x200 image |
clean_white |
Uniform 240-white image |
clean_dark |
Uniform 30-dark image |
few_dead |
Grey image with 5 planted dead pixels |
many_dead |
Grey image with 300+ dead pixels |
single_dead |
Single dead pixel at centre |
red_cast |
Dominant red channel (220, 30, 30) |
blue_cast |
Dominant blue channel |
green_cast |
Dominant green channel |
balanced_rgb |
Balanced 150-grey RGB image |
h_gradient |
Left-to-right brightness gradient 0 to 255 |
v_gradient |
Top-to-bottom brightness gradient |
checkerboard |
25px black-and-white checkerboard |
vignette |
Bright centre, dark edges (radial fade) |
| ID | Test description | Expected |
|---|---|---|
| TC-DP-01 | Clean uniform image reports 0 dead pixels | count == 0 |
| TC-DP-02 | Image with 10 planted dead pixels detects >= 1 | count >= 1 |
| TC-DP-03 | Dead pixels within threshold - passes() = True | True |
| TC-DP-04 | 50+ dead pixels exceeds threshold - passes() = False | False |
| TC-DP-05 | Every detected defect has location data (x, y) | location != None |
| TC-DP-06 | ValidationReport sets dead_pixel_result = FAIL | "FAIL" |
| ID | Test description | Expected |
|---|---|---|
| TC-CA-01 | Balanced grey image passes color validation | passes() = True |
| TC-CA-02 | Red-biased image flagged as color anomaly | R channel detected |
| TC-CA-03 | Blue-biased image flagged with B channel error | B channel detected |
| TC-CA-04 | Channel means returned per R / G / B | stats["R"]["mean"] > stats["G"]["mean"] |
| TC-CA-05 | Deviation percentage is always >= 0 | deviation >= 0.0 |
| TC-CA-06 | ValidationReport sets color_result = FAIL | "FAIL" |
| ID | Test description | Expected |
|---|---|---|
| TC-BV-01 | Uniform grey image passes brightness check | passes() = True |
| TC-BV-02 | Hard gradient image fails brightness uniformity | passes() = False |
| TC-BV-03 | Luminance stats include mean, std, min, max | All 4 keys present |
| TC-BV-04 | Gradient image has luminance std > 50 | std > 50 |
| TC-BV-05 | Uniform image has luminance std < 5 | std < 5 |
| TC-BV-06 | ValidationReport sets brightness_result = FAIL | "FAIL" |
| ID | Test description | Expected |
|---|---|---|
| TC-INT-01 | Clean image PASS on all 3 categories, score >= 90 | overall = "PASS" |
| TC-INT-02 | Report contains all required fields | All 6 fields present |
| TC-INT-03 | Quality score always in range [0, 100] | 0 <= score <= 100 |
| TC-INT-04 | to_json() produces parseable JSON | json.loads() succeeds |
| TC-INT-05 | Missing image raises FileNotFoundError | Exception raised |
| TC-INT-06 | Batch validation returns one report per image | len(reports) == 2 |
| TC-INT-07 | Summary totals equal number of validated images | passed + failed == 3 |
{
"image_path": "screenshots/screen_02_dead_pixels.png",
"timestamp": "2026-02-14T10:32:44.812301",
"width": 400,
"height": 300,
"overall_result": "FAIL",
"dead_pixel_result": "FAIL",
"color_result": "PASS",
"brightness_result": "PASS",
"dead_pixel_count": 20,
"dead_pixels": [
{
"category": "DEAD_PIXEL",
"location": { "x": 5, "y": 5, "width": 1, "height": 1 },
"pixel_value": [0, 0, 0],
"severity": "WARNING"
}
],
"color_defects": [],
"brightness_defects": [],
"score": 0.0
}Batch summary example:
{
"total_images": 10,
"passed": 4,
"failed": 6,
"pass_rate_pct": 40.0,
"avg_score": 83.5,
"dead_pixel_failures": 2,
"color_failures": 2,
"brightness_failures": 2
}from display_validator import DisplayValidator
validator = DisplayValidator()
report = validator.validate("screenshot.png")
print(report.overall_result) # "PASS" or "FAIL"
print(report.score) # 0.0 to 100.0
print(report.dead_pixel_count) # integer
print(report.color_defects) # list of dicts
print(report.brightness_defects) # list of dicts
print(report.to_json()) # full JSON stringfrom display_validator import DisplayValidator
validator = DisplayValidator()
paths = ["s01.png", "s02.png", "s03.png"]
reports = validator.validate_batch(paths)
summary = validator.generate_summary(reports)
print(summary["pass_rate_pct"]) # e.g. 66.7
print(summary["avg_score"]) # e.g. 85.0import cv2
from display_validator import DeadPixelDetector, ColorAnomalyDetector, BrightnessVariationDetector
image = cv2.imread("screenshot.png")
# Dead pixel detector
dp = DeadPixelDetector(threshold=15)
defects = dp.detect(image)
count = dp.count(image)
passed = dp.passes(image) # True / False
# Color anomaly detector
ca = ColorAnomalyDetector(deviation_threshold=20.0)
defs = ca.detect(image)
stats = ca.get_channel_means(image) # {"R": {"mean":..}, "G":.., "B":..}
# Brightness variation detector
bv = BrightnessVariationDetector(std_threshold=40.0, variation_pct=30.0, grid_size=(3,3))
defs = bv.detect(image)
stats = bv.get_luminance_stats(image) # {"mean":..,"std":..,"min":..,"max":..}from display_validator import DisplayValidator
from report_generator import ReportGenerator
from pathlib import Path
paths = [str(p) for p in Path("screenshots").glob("*.png")]
validator = DisplayValidator()
reports = validator.validate_batch(paths)
ReportGenerator().save(reports, output_path="qa_report.html")Or from the command line:
python report_generator.py ./screenshots/ qa_report.html
# Open in browser
open qa_report.html # macOS
xdg-open qa_report.html # Linux
start qa_report.html # WindowsThe HTML report includes:
- Summary metric cards (total, passed, failed, avg score)
- Per-category failure counts
- Per-image results table with thumbnails, PASS/FAIL badges, score bars, defect chips
- Fully self-contained: no internet or external files needed
All thresholds are centralised in the Thresholds class inside display_validator.py:
class Thresholds:
DEAD_PIXEL_INTENSITY = 15 # grayscale <= this -> dead pixel
DEAD_PIXEL_MAX_ALLOWED = 5 # fail if more than N dead pixel clusters
COLOR_CHANNEL_DEVIATION = 20.0 # % per-channel imbalance -> flag
BRIGHTNESS_STD_FAIL = 40.0 # luminance std-dev -> hard fail
BRIGHTNESS_STD_WARN = 25.0 # luminance std-dev -> warning
BRIGHTNESS_VARIATION_PCT = 30.0 # % zone variation -> fail
MIN_IMAGE_DIMENSION = 10 # minimum valid image side in pixels
SATURATION_THRESHOLD = 240 # channel value treated as saturatedTo adjust at runtime without editing the file:
from display_validator import DeadPixelDetector, BrightnessVariationDetector
# More strict: fail if any dead pixel at all
dp = DeadPixelDetector(threshold=20)
result = dp.passes(image, max_allowed=0)
# More lenient brightness check for UI-heavy screenshots
bv = BrightnessVariationDetector(std_threshold=60.0, variation_pct=50.0)# Terminal coverage summary
pytest test_display_quality.py --cov=display_validator --cov-report=term-missing
# HTML coverage report (open htmlcov/index.html in browser)
pytest test_display_quality.py --cov=display_validator --cov-report=html
# Full combined run: HTML test report + coverage together
pytest test_display_quality.py -v \
--html=test_report.html --self-contained-html \
--cov=display_validator --cov-report=htmlpip install opencv-python
# For headless servers or CI environments:
pip install opencv-python-headlesspip install pytestimport cv2
img = cv2.imread("path/to/image.png")
if img is None:
print("Check: file exists, path is correct, format supported (PNG/JPG/BMP/TIFF)")The default std-dev threshold of 40 is tuned for uniform test patterns. Real screenshots with varied UI content will naturally have higher variation. Increase the threshold:
from display_validator import BrightnessVariationDetector
bv = BrightnessVariationDetector(std_threshold=70.0, variation_pct=60.0)Run from the project root:
cd display-quality-validator
pytest test_display_quality.py -vSet-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserDisplay Quality Validation Tool · Python · OpenCV · Pytest · Dec 2025 - Feb 2026










