mirror of https://bitbucket.org/ausocean/av.git
resolve sharpness/saturation terminology issue, updated package names and code cleanup
This commit is contained in:
parent
6d97486876
commit
00e1390893
|
@ -6,7 +6,7 @@ AUTHORS
|
||||||
Russell Stanley <russell@ausocean.org>
|
Russell Stanley <russell@ausocean.org>
|
||||||
|
|
||||||
LICENSE
|
LICENSE
|
||||||
Copyright (C) 2020 the Australian Ocean Lab (AusOcean)
|
Copyright (C) 2021-2022 the Australian Ocean Lab (AusOcean)
|
||||||
|
|
||||||
It is free software: you can redistribute it and/or modify them
|
It is free software: you can redistribute it and/or modify them
|
||||||
under the terms of the GNU General Public License as published by the
|
under the terms of the GNU General Public License as published by the
|
||||||
|
@ -22,7 +22,7 @@ LICENSE
|
||||||
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package main
|
package turbidity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -63,15 +63,13 @@ func normalize(slice []float64) []float64 {
|
||||||
// Return the average of a slice.
|
// Return the average of a slice.
|
||||||
func average(slice []float64) float64 {
|
func average(slice []float64) float64 {
|
||||||
var out float64
|
var out float64
|
||||||
|
|
||||||
// Sum all elements in the slice.
|
|
||||||
for i := range slice {
|
for i := range slice {
|
||||||
out += slice[i]
|
out += slice[i]
|
||||||
}
|
}
|
||||||
return out / float64(len(slice))
|
return out / float64(len(slice))
|
||||||
}
|
}
|
||||||
|
|
||||||
func plotResults(x, saturation, contrast []float64) error {
|
func plotResults(x, sharpness, contrast []float64) error {
|
||||||
err := plotToFile(
|
err := plotToFile(
|
||||||
"Results",
|
"Results",
|
||||||
"Almond Milk (ml)",
|
"Almond Milk (ml)",
|
||||||
|
@ -79,7 +77,7 @@ func plotResults(x, saturation, contrast []float64) error {
|
||||||
func(p *plot.Plot) error {
|
func(p *plot.Plot) error {
|
||||||
return plotutil.AddLinePoints(p,
|
return plotutil.AddLinePoints(p,
|
||||||
"Contrast", plotterXY(x, contrast),
|
"Contrast", plotterXY(x, contrast),
|
||||||
"Saturation", plotterXY(x, saturation),
|
"Sharpness", plotterXY(x, sharpness),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -9,7 +9,7 @@ AUTHORS
|
||||||
Russell Stanley <russell@ausocean.org>
|
Russell Stanley <russell@ausocean.org>
|
||||||
|
|
||||||
LICENSE
|
LICENSE
|
||||||
Copyright (C) 2020 the Australian Ocean Lab (AusOcean)
|
Copyright (C) 2021-2022 the Australian Ocean Lab (AusOcean)
|
||||||
|
|
||||||
It is free software: you can redistribute it and/or modify them
|
It is free software: you can redistribute it and/or modify them
|
||||||
under the terms of the GNU General Public License as published by the
|
under the terms of the GNU General Public License as published by the
|
||||||
|
@ -25,27 +25,26 @@ LICENSE
|
||||||
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package main
|
package turbidity
|
||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// Results holds the results of the turbidity sensor.
|
// Results holds the results of the turbidity sensor.
|
||||||
type Results struct {
|
type Results struct {
|
||||||
Turbidity []float64
|
Turbidity []float64
|
||||||
Saturation []float64
|
Sharpness []float64
|
||||||
Contrast []float64
|
Contrast []float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewResults constructs the results object.
|
// NewResults returns a new Results
|
||||||
func NewResults(n int) (*Results, error) {
|
func NewResults(n int) (*Results, error) {
|
||||||
|
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return nil, fmt.Errorf("invalid result size: %v.", n)
|
return nil, fmt.Errorf("invalid result size: %v", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := new(Results)
|
r := new(Results)
|
||||||
r.Turbidity = make([]float64, n)
|
r.Turbidity = make([]float64, n)
|
||||||
r.Saturation = make([]float64, n)
|
r.Sharpness = make([]float64, n)
|
||||||
r.Contrast = make([]float64, n)
|
r.Contrast = make([]float64, n)
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
|
@ -53,7 +52,7 @@ func NewResults(n int) (*Results, error) {
|
||||||
|
|
||||||
// Update adds new values to slice at specified index.
|
// Update adds new values to slice at specified index.
|
||||||
func (r *Results) Update(newSat, newCont, newTurb float64, index int) {
|
func (r *Results) Update(newSat, newCont, newTurb float64, index int) {
|
||||||
r.Saturation[index] = newSat
|
r.Sharpness[index] = newSat
|
||||||
r.Contrast[index] = newCont
|
r.Contrast[index] = newCont
|
||||||
r.Turbidity[index] = newTurb
|
r.Turbidity[index] = newTurb
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,16 @@
|
||||||
|
|
||||||
/*
|
/*
|
||||||
DESCRIPTION
|
DESCRIPTION
|
||||||
Holds the turbidity sensor struct. Can evaluate 4x4 chessboard markers
|
Holds the turbidity sensor struct. Can evaluate 4x4 chessboard markers in an
|
||||||
in an image to measure the sharpness and contrast.
|
image to measure the sharpness and contrast. This implementation is based off
|
||||||
|
a master thesis from Aalborg University, Turbidity measurement based on computer vision.
|
||||||
|
The full paper is avaible at https://projekter.aau.dk/projekter/files/306657262/master.pdf
|
||||||
|
|
||||||
AUTHORS
|
AUTHORS
|
||||||
Russell Stanley <russell@ausocean.org>
|
Russell Stanley <russell@ausocean.org>
|
||||||
|
|
||||||
LICENSE
|
LICENSE
|
||||||
Copyright (C) 2020 the Australian Ocean Lab (AusOcean)
|
Copyright (C) 2021-2022 the Australian Ocean Lab (AusOcean)
|
||||||
|
|
||||||
It is free software: you can redistribute it and/or modify them
|
It is free software: you can redistribute it and/or modify them
|
||||||
under the terms of the GNU General Public License as published by the
|
under the terms of the GNU General Public License as published by the
|
||||||
|
@ -26,7 +28,7 @@ LICENSE
|
||||||
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package main
|
package turbidity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
@ -37,7 +39,7 @@ import (
|
||||||
"gocv.io/x/gocv"
|
"gocv.io/x/gocv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TurbiditySensor is a software based turbidity sensor that uses CV to determine saturation and constrast level
|
// TurbiditySensor is a software based turbidity sensor that uses CV to determine sharpness and constrast level
|
||||||
// of a chessboard-like target submerged in water that can be correlated to turbidity/visibility values.
|
// of a chessboard-like target submerged in water that can be correlated to turbidity/visibility values.
|
||||||
type TurbiditySensor struct {
|
type TurbiditySensor struct {
|
||||||
template, templateCorners gocv.Mat
|
template, templateCorners gocv.Mat
|
||||||
|
@ -46,7 +48,7 @@ type TurbiditySensor struct {
|
||||||
scale, alpha float64
|
scale, alpha float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTurbiditySensor constructor for a turbidity sensor.
|
// NewTurbiditySensor returns a new TurbiditySensor.
|
||||||
func NewTurbiditySensor(template, standard gocv.Mat, k1, k2, sobelFilterSize int, scale, alpha float64) (*TurbiditySensor, error) {
|
func NewTurbiditySensor(template, standard gocv.Mat, k1, k2, sobelFilterSize int, scale, alpha float64) (*TurbiditySensor, error) {
|
||||||
ts := new(TurbiditySensor)
|
ts := new(TurbiditySensor)
|
||||||
templateCorners := gocv.NewMat()
|
templateCorners := gocv.NewMat()
|
||||||
|
@ -54,7 +56,7 @@ func NewTurbiditySensor(template, standard gocv.Mat, k1, k2, sobelFilterSize int
|
||||||
|
|
||||||
// Validate template image is not empty and has valid corners.
|
// Validate template image is not empty and has valid corners.
|
||||||
if template.Empty() {
|
if template.Empty() {
|
||||||
return nil, errors.New("template image is empty.")
|
return nil, errors.New("template image is empty")
|
||||||
}
|
}
|
||||||
if !gocv.FindChessboardCorners(template, image.Pt(3, 3), &templateCorners, gocv.CalibCBNormalizeImage) {
|
if !gocv.FindChessboardCorners(template, image.Pt(3, 3), &templateCorners, gocv.CalibCBNormalizeImage) {
|
||||||
return nil, errors.New("could not find corners in template image")
|
return nil, errors.New("could not find corners in template image")
|
||||||
|
@ -64,7 +66,7 @@ func NewTurbiditySensor(template, standard gocv.Mat, k1, k2, sobelFilterSize int
|
||||||
|
|
||||||
// Validate standard image is not empty and has valid corners.
|
// Validate standard image is not empty and has valid corners.
|
||||||
if standard.Empty() {
|
if standard.Empty() {
|
||||||
return nil, errors.New("standard image is empty.")
|
return nil, errors.New("standard image is empty")
|
||||||
}
|
}
|
||||||
if !gocv.FindChessboardCorners(standard, image.Pt(3, 3), &standardCorners, gocv.CalibCBNormalizeImage) {
|
if !gocv.FindChessboardCorners(standard, image.Pt(3, 3), &standardCorners, gocv.CalibCBNormalizeImage) {
|
||||||
return nil, errors.New("could not find corners in standard image")
|
return nil, errors.New("could not find corners in standard image")
|
||||||
|
@ -74,7 +76,6 @@ func NewTurbiditySensor(template, standard gocv.Mat, k1, k2, sobelFilterSize int
|
||||||
|
|
||||||
ts.k1, ts.k2, ts.sobelFilterSize = k1, k2, sobelFilterSize
|
ts.k1, ts.k2, ts.sobelFilterSize = k1, k2, sobelFilterSize
|
||||||
ts.alpha, ts.scale = alpha, scale
|
ts.alpha, ts.scale = alpha, scale
|
||||||
|
|
||||||
return ts, nil
|
return ts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,28 +83,22 @@ func NewTurbiditySensor(template, standard gocv.Mat, k1, k2, sobelFilterSize int
|
||||||
func (ts TurbiditySensor) Evaluate(imgs []gocv.Mat) (*Results, error) {
|
func (ts TurbiditySensor) Evaluate(imgs []gocv.Mat) (*Results, error) {
|
||||||
result, err := NewResults(len(imgs))
|
result, err := NewResults(len(imgs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return nil, fmt.Errorf("could not create results: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range imgs {
|
for i := range imgs {
|
||||||
// Transform image.
|
|
||||||
marker, err := ts.transform(imgs[i])
|
marker, err := ts.transform(imgs[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("image %v: %w", i, err)
|
return nil, fmt.Errorf("could not transform image: %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sobel filter.
|
|
||||||
edge := ts.sobel(marker)
|
edge := ts.sobel(marker)
|
||||||
|
|
||||||
// Evaluate image.
|
|
||||||
sharpScore, contScore, err := ts.EvaluateImage(marker, edge)
|
sharpScore, contScore, err := ts.EvaluateImage(marker, edge)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Update(sharpScore, contScore, float64(i), i)
|
result.Update(sharpScore, contScore, float64(i), i)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,19 +110,16 @@ func (ts TurbiditySensor) EvaluateImage(img, edge gocv.Mat) (float64, float64, e
|
||||||
if img.Rows()%ts.k1 != 0 || img.Cols()%ts.k2 != 0 {
|
if img.Rows()%ts.k1 != 0 || img.Cols()%ts.k2 != 0 {
|
||||||
return math.NaN(), math.NaN(), fmt.Errorf("dimensions not compatible (%v, %v)", ts.k1, ts.k2)
|
return math.NaN(), math.NaN(), fmt.Errorf("dimensions not compatible (%v, %v)", ts.k1, ts.k2)
|
||||||
}
|
}
|
||||||
|
|
||||||
lStep := img.Rows() / ts.k1
|
lStep := img.Rows() / ts.k1
|
||||||
kStep := img.Cols() / ts.k2
|
kStep := img.Cols() / ts.k2
|
||||||
|
|
||||||
for l := 0; l < img.Rows(); l += lStep {
|
for l := 0; l < img.Rows(); l += lStep {
|
||||||
for k := 0; k < img.Cols(); k += kStep {
|
for k := 0; k < img.Cols(); k += kStep {
|
||||||
// Enhancement Measure Estimation (EME), provides a measure of the sharpness.
|
// Enhancement Measure Estimation (EME), provides a measure of the sharpness.
|
||||||
sharpValue := ts.evaluateBlockEME(edge, l, k, l+lStep, k+kStep)
|
sharpness += ts.evaluateBlockEME(edge, l, k, l+lStep, k+kStep)
|
||||||
sharpness += sharpValue
|
|
||||||
|
|
||||||
// AMEE, provides a measure of the contrast.
|
// AMEE, provides a measure of the contrast.
|
||||||
contValue := ts.evaluateBlockAMEE(img, l, k, l+lStep, k+kStep)
|
contrast += ts.evaluateBlockAMEE(img, l, k, l+lStep, k+kStep)
|
||||||
contrast += contValue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,7 +157,7 @@ func (ts TurbiditySensor) minMax(img gocv.Mat, xStart, yStart, xEnd, yEnd int) (
|
||||||
func (ts TurbiditySensor) evaluateBlockEME(img gocv.Mat, xStart, yStart, xEnd, yEnd int) float64 {
|
func (ts TurbiditySensor) evaluateBlockEME(img gocv.Mat, xStart, yStart, xEnd, yEnd int) float64 {
|
||||||
max, min := ts.minMax(img, xStart, yStart, xEnd, yEnd)
|
max, min := ts.minMax(img, xStart, yStart, xEnd, yEnd)
|
||||||
|
|
||||||
// Blocks which have no information are ignored.
|
// Blocks where all pixel values are equal are ignored to avoid division by 0.
|
||||||
if max != -math.MaxFloat64 && min != math.MaxFloat64 && max != min {
|
if max != -math.MaxFloat64 && min != math.MaxFloat64 && max != min {
|
||||||
return math.Log(max / min)
|
return math.Log(max / min)
|
||||||
}
|
}
|
||||||
|
@ -176,7 +168,7 @@ func (ts TurbiditySensor) evaluateBlockEME(img gocv.Mat, xStart, yStart, xEnd, y
|
||||||
func (ts TurbiditySensor) evaluateBlockAMEE(img gocv.Mat, xStart, yStart, xEnd, yEnd int) float64 {
|
func (ts TurbiditySensor) evaluateBlockAMEE(img gocv.Mat, xStart, yStart, xEnd, yEnd int) float64 {
|
||||||
max, min := ts.minMax(img, xStart, yStart, xEnd, yEnd)
|
max, min := ts.minMax(img, xStart, yStart, xEnd, yEnd)
|
||||||
|
|
||||||
// Blocks which have no information are ignored.
|
// Blocks where all pixel values are equal are ignored to avoid division by 0.
|
||||||
if max != -math.MaxFloat64 && min != math.MaxFloat64 && max != min {
|
if max != -math.MaxFloat64 && min != math.MaxFloat64 && max != min {
|
||||||
contrast := (max + min) / (max - min)
|
contrast := (max + min) / (max - min)
|
||||||
return math.Pow(ts.alpha*(contrast), ts.alpha) * math.Log(contrast)
|
return math.Pow(ts.alpha*(contrast), ts.alpha) * math.Log(contrast)
|
||||||
|
@ -189,8 +181,12 @@ func (ts TurbiditySensor) transform(img gocv.Mat) (gocv.Mat, error) {
|
||||||
out := gocv.NewMat()
|
out := gocv.NewMat()
|
||||||
mask := gocv.NewMat()
|
mask := gocv.NewMat()
|
||||||
imgCorners := gocv.NewMat()
|
imgCorners := gocv.NewMat()
|
||||||
|
const (
|
||||||
|
ransacThreshold = 3.0 // Maximum allowed reprojection error to treat a point pair as an inlier.
|
||||||
|
maxIter = 2000 // The maximum number of RANSAC iterations.
|
||||||
|
confidence = 0.995 // Confidence level, between 0 and 1.
|
||||||
|
)
|
||||||
|
|
||||||
// Check image is valid.
|
|
||||||
if img.Empty() {
|
if img.Empty() {
|
||||||
return out, errors.New("image is empty, cannot transform")
|
return out, errors.New("image is empty, cannot transform")
|
||||||
}
|
}
|
||||||
|
@ -200,7 +196,7 @@ func (ts TurbiditySensor) transform(img gocv.Mat) (gocv.Mat, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and apply transformation.
|
// Find and apply transformation.
|
||||||
H := gocv.FindHomography(imgCorners, &ts.templateCorners, gocv.HomograpyMethodRANSAC, 3.0, &mask, 2000, 0.995)
|
H := gocv.FindHomography(imgCorners, &ts.templateCorners, gocv.HomograpyMethodRANSAC, ransacThreshold, &mask, maxIter, confidence)
|
||||||
gocv.WarpPerspective(img, &out, H, image.Pt(ts.template.Rows(), ts.template.Cols()))
|
gocv.WarpPerspective(img, &out, H, image.Pt(ts.template.Rows(), ts.template.Cols()))
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
|
@ -7,7 +7,7 @@ AUTHORS
|
||||||
Russell Stanley <russell@ausocean.org>
|
Russell Stanley <russell@ausocean.org>
|
||||||
|
|
||||||
LICENSE
|
LICENSE
|
||||||
Copyright (C) 2020 the Australian Ocean Lab (AusOcean)
|
Copyright (C) 2021-2022 the Australian Ocean Lab (AusOcean)
|
||||||
|
|
||||||
It is free software: you can redistribute it and/or modify them
|
It is free software: you can redistribute it and/or modify them
|
||||||
under the terms of the GNU General Public License as published by the
|
under the terms of the GNU General Public License as published by the
|
||||||
|
@ -23,7 +23,7 @@ LICENSE
|
||||||
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
in gpl.txt. If not, see http://www.gnu.org/licenses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package main
|
package turbidity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -33,12 +33,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
nImages = 13 // Number of images to test. (Max 13)
|
nImages = 13 // Number of images to test. (Max 13)
|
||||||
nSamples = 10 // Number of samples for each image. (Max 10)
|
nSamples = 10 // Number of samples for each image. (Max 10)
|
||||||
increment = 2.5
|
increment = 2.5 // Increment of the turbidity level
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestImages(t *testing.T) {
|
func TestImages(t *testing.T) {
|
||||||
|
const (
|
||||||
|
k1, k2 = 8, 8
|
||||||
|
filterSize = 3
|
||||||
|
scale, alpha = 1.0, 1.0
|
||||||
|
)
|
||||||
|
|
||||||
// Load template and standard image.
|
// Load template and standard image.
|
||||||
template := gocv.IMRead("images/template.jpg", gocv.IMReadGrayScale)
|
template := gocv.IMRead("images/template.jpg", gocv.IMReadGrayScale)
|
||||||
standard := gocv.IMRead("images/default.jpg", gocv.IMReadGrayScale)
|
standard := gocv.IMRead("images/default.jpg", gocv.IMReadGrayScale)
|
||||||
|
@ -54,15 +60,15 @@ func TestImages(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create turbidity sensor.
|
// Create turbidity sensor.
|
||||||
ts, err := NewTurbiditySensor(template, standard, 8, 8, 3, 1.0, 1.0)
|
ts, err := NewTurbiditySensor(template, standard, k1, k2, filterSize, scale, alpha)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal("could not create turbidity sensor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create results.
|
// Create results.
|
||||||
results, err := NewResults(nImages)
|
results, err := NewResults(nImages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal("could not create results: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score each image by calculating the average score from camera burst.
|
// Score each image by calculating the average score from camera burst.
|
||||||
|
@ -70,19 +76,19 @@ func TestImages(t *testing.T) {
|
||||||
// Evaluate camera burst.
|
// Evaluate camera burst.
|
||||||
sample_result, err := ts.Evaluate(imgs[i])
|
sample_result, err := ts.Evaluate(imgs[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Evaluation Failed: %v", err)
|
t.Fatalf("evaluation Failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the average result from camera burst.
|
// Add the average result from camera burst.
|
||||||
results.Update(average(sample_result.Saturation), average(sample_result.Contrast), float64(i)*increment, i)
|
results.Update(average(sample_result.Sharpness), average(sample_result.Contrast), float64(i)*increment, i)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plot the final results.
|
// Plot the final results.
|
||||||
err = plotResults(results.Turbidity, normalize(results.Saturation), normalize(results.Contrast))
|
err = plotResults(results.Turbidity, normalize(results.Sharpness), normalize(results.Contrast))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Plotting Failed: %v", err)
|
t.Fatalf("plotting Failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Saturation: %v", results.Saturation)
|
t.Logf("Sharpness: %v", results.Sharpness)
|
||||||
t.Logf("Contrast: %v", results.Contrast)
|
t.Logf("Contrast: %v", results.Contrast)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue