snibgo's ImageMagick pages

Finding and analysing colour charts

We reduce a chart of colour patches in a photo to one pixel per patch.

Colour checker charts help us with colour correction (aka colour grading) between shots taken under different lighting, or with different lenses or cameras. Some colour-correcting systems need the user to find the card in the image, and identify four points on the card. However, the task can be automated fairly easily.

This page describes how IM can find the colour chart within the photograph and reduce it to one pixel per colour patch. The chart shown here has 6x4 patches, so is reduced to a 6x4 pixel image.

Another page, Colour checker charts, shows how those pixels are used to calculate the best colour matrix that would transform one photographed chart to another, and hence can be applied to a set of photos (or video frames) to make that set match another set or an absolute standard.

Scripts on this page assume that the version of ImageMagick in %IM7DEV% has been built with various process modules. See Process modules.

References

The chart

ftc_snib_cht.png

Visible light occurs at a range of frequencies. Objects appear certain colours because they reflect different frequencies of light in differing proportions, for example a red object appears red because it reflects red light and absorbs other frequencies. If red and blue patches have the same intensity in white light, then under red light the red patch will be brighter than the blue patch, and a blue light will have the opposite effect.

But different objects reflect light in different ways, so objects that appear to be the same colour in one light may seem to be different colours under some other light; the objects have different spectral responses.

Some colour charts use paint rather than a four-colour printing process. The idea is that under varying light conditions each patch reflects different frequencies in the same way as some real-world object (human skin, plant leaves, flowers etc), so a colour transformation that corrects the chart will also make those real-world objects correct.

Ideal charts are Lambertian reflectors (matte, not glossy, so no specular highlights), with well-defined registration marks and dimensions to assist searching and data processing. They are standardised and consistent, so replacements are identical, and the various professions in the image-processing chain use the same standard. The colours should be a fair representation of the colours actually photographed, with no bias towards (or against) certain hues, saturations or tones. And ideal cards are durable.

Continuous gradations, rather than just a small number of discrete steps, would be useful.

I've never seen an ideal chart.

Example photos

For each photograph that includes a colour chart, we run a script, 24card.bat, that finds the chart, squares it up and reduces each colour patch to a single pixel, so we have an image of 6x4 pixels.

The script assumes the card is within about 15° of horizontal in the photo, oriented as shown, with the gray patches at the bottom. All patches must be entirely visible, with even illumination (eg no visible shadows), and be at least 20x20 pixels in the photograph. Lines that are straight on the chart should be straight on the photo.

The card should be roughly perpendicular to the lens axis, ie "flat on" towards the camera. But it mustn't act like a mirror to the light source. When that happens, the card will have hot spots that will cause problems, so the card should be angled away to reduce the problem.

The photos don't need to have the charts in perfect focus, provided the central 75% of each patch is pure, with no overlap from a border or another patch.

Here are two photographs taken with a Nikon D800 and a GoPro Hero 3. They were taken at night, in a room with household LED lighting (nominally 2700K). The Nikon used an 85mm lens wide open, ISO 2000, f/1.8 @ 1/250s, with white balance set to auto. The dcraw conversion used the white balance from the camera. The GoPro has a 3mm lens (equivalent to 15mm on a 35mm camera), at ISO 368, f/2.8 @ 1/12s.

I also scanned the card with a Canon CanoScan 5600F scanner.

Operations are performed on full-size images, with results resized for the web.

set SRCDIR=%PICTLIB%20171021\
set RESWEB=-resize 600

Nikon D800 in-camera JPEG.

set NIKON_JPG=%SRCDIR%AGA_3409.JPG

%IMG7%magick ^
  %NIKON_JPG% ^
  %RESWEB% ^
  +depth ^
  ftc_ph1.png
ftc_ph1.pngjpg

Nikon D800 raw NEF, converted by dcraw.

set NIKON_sRGB=%SRCDIR%AGA_3409_sRGB.tiff

%IMG7%magick ^
  %NIKON_sRGB% ^
  %RESWEB% ^
  +depth ^
  ftc_ph2.png
ftc_ph2.pngjpg

GoPro Hero 3

set GOPRO_ORIG=%SRCDIR%GOPR0395.JPG

%IMG7%magick ^
  %GOPRO_ORIG% ^
  %RESWEB% ^
  +depth ^
  ftc_ph3.png
ftc_ph3.pngjpg

GoPro, rectified

(See De-barrel distortion.)

set GOPRO_RECT=%SRCDIR%GOPR0395_rgp.tiff

call %PICTBAT%rectGoPro ^
  %GOPRO_ORIG% 1 . . %GOPRO_RECT%

if ERRORLEVEL 1 goto error

%IMG7%magick ^
  %GOPRO_RECT% ^
  %RESWEB% ^
  +depth ^
  ftc_ph4.png
ftc_ph4.pngjpg

Scanner

set SCAN=%SRCDIR%24cardScan.tiff

%IMG7%magick ^
  %SCAN% ^
  %RESWEB% ^
  +depth ^
  ftc_ph5.png
ftc_ph5.pngjpg

In the GoPro image, the small chart on the right badly reflects from the overhead room light (we also see its reflection in the laptop screen). The main chart in the centre is also somewhat affected; the top-right border is lighter than the rest of the border.

Reduce photo to N pixels

From each photo that includes a colour chart, we want to make an image that contains one pixel per colour patch, being the mean of that patch. The method for doing this will vary according to the nature of the card and style of photography.

The script 24card.bat extracts the 24 patches from each, and scales up the result so we can see it. It checks that each found rectangle has a fairly low standard deviation, which suggests it doesn't straddle two or more patches.

The script takes two, three or four parameters:

%1 Input filename
%2 Template for output filename
%3 Optional debugging level: 0,1 or 2.
%4 Optional, either tryRot or notTryRot.

It creates a number of output image files. Parameter %2 must contain XX, which will be substituted. For example, if the second parameter is myfile_XX.png, possible output files are:

If a fourth parameter is given, it must be either "tryRot" or "notTryRot" (any case, with or without quotes). "tryRot" means the script will try to find the card at any rotation; it doesn't assume the card is roughly horizontal. However, this doubles the time needed. The default is "notTryRot".

The script writes temporary files to \temp\. These are useful for debugging, so the script doesn't delete the files. The directory should be emptied regularly.

With 35 MP images, the script typically takes about 10 seconds to find the solution (with "notTryRot"), which is almost entirely spent on the subimage searches. Sadly, when it finds no solution (perhaps there is no card in the photograph, or it is overexposed), it takes a minute or so trying the various possibilities.

If the script fails because of one or more "Bad patch" problems, it will create both "_dbg" and "_mat" images. The user should review these to evaluate whether the "badness" is acceptable.

Nikon D800 in-camera JPEG.

Also create a debugging image.

call %PICTBAT%24card ^
  %NIKON_JPG% ^
  ftc_ph1_XX.png ^
  1

if ERRORLEVEL 1 goto error

call %PICTBAT%bottomLineGray ^
  ftc_ph1_mat.png 
0.0997761
ftc_ph1_scl.png

Nikon D800 raw NEF, converted by dcraw.

call %PICTBAT%24card ^
  %NIKON_sRGB% ^
  ftc_ph2_XX.png

if ERRORLEVEL 1 goto error

call %PICTBAT%bottomLineGray ^
  ftc_ph2_mat.png 
0.0836699
ftc_ph2_scl.png

GoPro, rectified

call %PICTBAT%24card ^
  %GOPRO_RECT% ^
  ftc_ph4_XX.png

if ERRORLEVEL 1 goto error

call %PICTBAT%bottomLineGray ^
  ftc_ph4_mat.png 
0.00801357
ftc_ph4_scl.png

Scanner

call %PICTBAT%24card ^
  %SRCDIR%24cardScan.tiff ^
  ftc_ph5_XX.png

if ERRORLEVEL 1 goto error

call %PICTBAT%bottomLineGray ^
  ftc_ph5_mat.png 
0.00731214
ftc_ph5_scl.png

24card.bat also creates environment variables giving the geometric transformation from the input to the debugging image (even if it hasn't created one), and the coordinates of the four initial points in the debugging image.

echo sTRANS24c=%sTRANS24c% 
echo EIGHTNUM24c=%EIGHTNUM24c% 
sTRANS24c=-auto-orient -resize "500^>" -crop 1355x1355+0+0 +repage 
EIGHTNUM24c=137,135 588,135 585,355 138,359 

The debugging image ftc_ph1_dbg.png is:

ftc_ph1_dbg.png

ftc_ph1_dbg.pngjpg

The script finds the four points circled in the debugging image in orange, extrapolates from those to the points circled in cyan, and from those to the points circled in green. This defines four corners, so "-distort Perspective" makes this a rectangle which is divided into 24 pieces, and each is cropped to the central 75%, then scaled to a single pixel.

In normal use, the debug image is made only when no solution is found. The debug images can be manually viewed to see whether a card should have been found.

Typical usage

More typically, the card occupies a small part of the frame.

A source image:

set SRC=%PICTLIB%20171029\AGA_3431_sRGB.tiff

%IMG7%magick ^
  %SRC% ^
  %RESWEB% ^
  +depth ^
  ftc_typ_sm.miff
ftc_typ_sm.miffjpg

Run the script:

call %PICTBAT%24card ^
  %SRC% ftc_typ_XX.png 2

The 6x4 image ftc_typ_mat.png is shown.

ftc_typ_mat.png

Show the _scl image:

ftc_typ_scl.png

ftc_typ_scl.png

Show the _dbg image:

ftc_typ_dbg.png

ftc_typ_dbg.pngjpg

The _mat image is the useful one for further processing. The _scl image is useful for human viewing. We don't normally need the _dbg image, but we create and show it for interest.

I first created this image with dcraw with the usual parameters for sRGB. The two lighter patches (bottom-left of the card) almost merged; the lightest patch clipped to white, and the second clipped in the blue and green channels. This was caused by dcraw "auto-brighten", which permits 1% of the pixels to clip, which is a very high percentage. (A crop that is 10% of the image width and 10% of the height amounts to 1% of the image.) Re-processing with a hacked version of dcraw, allowing only 0.01% of pixels to clip, cured the problem.

Graphing the colours of the gray patches can be instructive:

A source image:

%IMG7%magick ^
  ftc_typ_mat.png ^
  -gravity South ^
    -crop x1+0+0 +repage ^
  -scale "600x1^!" ^
  ftc_typ_mat_gr.png

call %PICTBAT%graphLineCol ^
  ftc_typ_mat_gr.png
ftc_typ_mat_gr_glc.png

The patches mostly have more blue than green, and all have more green than red. The ratio between the channels is roughly constant, so a simple channel multiplication will make the grays more neutral. See the dcraw and WB page.

Using explicit coordinates

We might want to extract the 24 pixels by directly supplying the coordinates of the four initial points circled in orange above, instead of automatically finding them, because:

The script 24card4pt.bat takes four coordinate pairs as the fourth parameter. This is a list of eight numbers, separated by commas or spaces, usually in a quoted string. The first coordinates must be of the intersection nearest the top-left of the card when it is viewed normally. Then list the top-right, bottom-right and bottom-left coordinates.

Use Gimp or similar image editor to find the coordinates.

For example, we extract data for the small card in the Nikon NEF image:

set EightNum=6473,2569 6491,3499 6018,3498 6004,2573

call %PICTBAT%24card4pt ^
  %NIKON_sRGB% ftc_smcard_XX.png . "%EightNum%"
ftc_smcard_scl.png

The process is fast (eg 2 seconds for a 35 MP image), unless a debugging image is requested.

Extraction by proxy

echo sTRANS24c=%sTRANS24c% 
echo EIGHTNUM24c=%EIGHTNUM24c% 
sTRANS24c=-auto-orient -resize "1000^>" -crop 539x539+34+587 +repage -distort SRT 1,1.28014 +repage  
EIGHTNUM24c=181,182 359,182 361,269 182,270 

With dcraw we create a raw-colour version with no white balance, no auto-brighten, and no sRGB conversion. We assume this is linear RGB. We will transform this in the same way and extract the 24 colours:

set SRCNEF=%PICTLIB%20171029\AGA_3431.nef

%IMG692%dcraw -6 -o 0 -T -W -r 1 1 1 1 -O ftc_nef.tiff %SRCNEF%
%IMG7%magick ^
  ftc_nef.tiff ^
  %RESWEB% ^
  ftc_nef_sm.miff
ftc_nef_sm.miffjpg

Can 24card.bat find the card in this dark, difficult, image and analyse it? Surely not? Well, yes, it can:

Raw-colour input.

Also create a debugging image.

call %PICTBAT%24card ^
  ftc_nef.tiff ^
  ftc_nef_XX.png ^
  1

if ERRORLEVEL 1 goto error

call %PICTBAT%bottomLineGray ^
  ftc_nef_mat.png 
0.0499553
ftc_nef_scl.png
ftc_nef_dbg.pngjpg

Let's pretend 24card.bat couldn't find the card. We can use the environment variables from a successful 24card.bat for an easy proxy image.

call %PICTBAT%24card ^
  %SRC% ftc_dump_XX.png

echo sTRANS24c=%sTRANS24c% 
echo EIGHTNUM24c=%EIGHTNUM24c% 
sTRANS24c=-auto-orient -resize "1000^>" -crop 539x539+34+587 +repage -distort SRT 1,1.28014 +repage  
EIGHTNUM24c=181,182 359,182 361,269 182,270 

Now we distort and crop the difficult image in the same way, and use the same eight numbers as coordinates in the card.

%IMG7%magick ^
  ftc_nef.tiff ^
  %sTRANS24c% ^
  ftc_diff.miff

call %PICTBAT%24card4pt ^
  ftc_diff.miff ^
  ftc_diff_XX.png 2 "%EIGHTNUM24c%"
ftc_diff_dbg.pngjpg

Yes, this works fine. If we can find the card in one version of an image, we can use that information to find the card in any version of the same image.

Simple analysis

The RMSE between any pair of 6x4 images is a measure of how far they differ.

%IMG7%magick compare -metric RMSE ftc_ph1_mat.png ftc_ph2_mat.png NULL: 
9584.41 (0.146249)
%IMG7%magick compare -metric RMSE ftc_ph1_mat.png ftc_ph4_mat.png NULL: 
13368.6 (0.203992)
%IMG7%magick compare -metric RMSE ftc_ph1_mat.png ftc_ph5_mat.png NULL: 
7915.92 (0.120789)

Another useful metric is how far the bottom line differs from grayscale:

%IMG7%magick ^
  ftc_ph1_mat.png ^
  -gravity south -crop x1+0+0 +repage ^
  ( +clone -colorspace Gray ) ^
  -metric RMSE -format %%[distortion] -compare ^
  info: 
0.0997761

This is useful, so we put in a script, bottomLineGray.bat.

call %PICTBAT%bottomLineGray ftc_ph1_mat.png 
0.0997761
call %PICTBAT%bottomLineGray ftc_ph2_mat.png 
0.0836699
call %PICTBAT%bottomLineGray ftc_ph4_mat.png 
0.00801357
call %PICTBAT%bottomLineGray ftc_ph5_mat.png 
0.00731214

How does it work?

A number of BAT scripts are used. Writing it as C code wouldn't improve performance much.

24card.bat makes the four subimages (FIND0 etc) that are to be searched for, then calls 24cardOneSize.bat at up to four different scales until a solution is found. If no solution is found, it fails.

24cardOneSize.bat does the hard work. It orients and shrinks the image to the required size, then (if requested) calls 24cardRot.bat to find the best angle of rotation. Now the image is assumed to be within plus or minus 15° of horizontal. 24cardOneSize.bat finds the top two points and crops accordingly. Then it finds the top two points and rotates until the top points are on the same or adjacent rows. (If it can't do this, or the angle is more than 45°, the card has not been found at this size.) Then it finds the bottom two points. Now we have four points, and after checking that the top-left point really is to the left of the bottom-right point, and so on, it calls 24cardChop.bat to extract the patch data, then calls 24cardDbg.bat in case we need a debugging image.

24card4pt.bat parses the arguments including the initial four intersection points, calls 24cardChop.bat to extract the patch data, then calls 24cardDbg.bat in case we need a debugging image.

24cardChop.bat is given the initial four initial intersection points, extrapolates these out to the corners, then crops and straightens up the image, divides it into 24 patches, reports any excessive standard deviations, scales the patches to 1x1 and appends them into a 6x4 image, and scales that up.

24cardRot.bat writes and runs a script, then analyses the output. The script searches for the subimage within the main image at various rotations of the subimage: 0°, -30°, ..., -320°. The rotation that gives the best RMSE score is returned, negated (because the caller will want to rotate the main image in the opposite direction). Greater precision is not required. The location found is ignored; we care only about the best angle. The work is done in a single magick, so the images are not re-read for each angle. An equivalent process module would be slightly faster as it wouldn't need to resize the input at each angle.

24cardDbg.bat writes a version of the main image, with up to 12 points circled in different colours.

All subimage searches are done by "-process rmsealpha" which does a pyramid search with a minimum dimension of just under half the subimage size, weighting pixels according to alpha, and normalising mean and SD.

I could have used my (unpublished) general-purpose subimage search that copes with any angle and scale. Using that is simpler, but execution takes longer. For this job, the subimages are simple so an approximate angle is good enough, and they are close to being invariant to scale. (If the patches didn't have black boundaries between them, it would be truly invariant to scale, and the task would be much simpler.)

Next steps

The script 24card.bat has reduced each photo (or video frame) that includes a colour card to a small image of 6x4 pixels. We can use these small images to balance the grays for individual images, or tweak images to match some reference image or standard numbers, perhaps also tweaking images to adjust hue or saturation or contrast. Details are in Colour checker charts.

Scripts

For convenience, .bat scripts are also available in a single zip file. See Zipped BAT files.

24card.bat

rem From image %1 that contains a 24-colour card,
rem make image files %2, must contain XX.
rem %3 if 1, debug mode.
rem %4 "tryRot" or "notTryRot" [default "notTryRot"]
rem 
@rem
@rem Updated:
@rem   22-August-2022 Upgraded for IM v7.
@rem

@if "%1"=="" findstr /B "rem @rem" %~f0 & exit /B 1

@setlocal enabledelayedexpansion

@call echoOffSave

call %PICTBAT%setInOut %1 24c

if not "%2"=="" if not "%2"=="." set OUTFILE=%2

set DEBUG=%3
if "%DEBUG%"=="." set DEBUG=
if "%DEBUG%"=="" set DEBUG=0

set sTryRot=%~4
if "%sTryRot%"=="." set sTryRot=
if "%sTryRot%"=="" set sTryRot=notTryRot

if /I %sTryRot%==tryRot (
  set TRY_ROT=1
) else if /I %sTryRot%==notTryRot (
  set TRY_ROT=0
) else (
  echo Bad parameter: [%sTryRot%] should be tryRot or notTryRot
  exit /B 1
)


set OUTEXT=%OUTFILE:*.=%
echo OUTEXT=%OUTEXT%

set OUT_MAT=%OUTFILE:XX=mat%
set OUT_SCL=%OUTFILE:XX=scl%
set OUT_DBG=%OUTFILE:XX=dbg%

if "%OUT_MAT%"=="%OUTFILE%" (
  echo Outfile needs XX
  exit /B 1
)

set TMPDIR=\temp\

set FIND0=%TMPDIR%24c_f0.png
set FIND1=%TMPDIR%24c_f1.png
set FIND2=%TMPDIR%24c_f2.png
set FIND3=%TMPDIR%24c_f3.png

set /A COLS=15
set COLSIZ=-size %COLS%x%COLS%

set SEMI_SP=1
set /A SPL_DIM=2*%SEMI_SP%+1

set SPLIC=-background Black -gravity Center -splice %SPL_DIM%x%SPL_DIM% -gravity None

:: To be really cute,
:: we could adjust the following colours according
:: to the recorded white balance.

:: Four subimages to search for, clockwise, starting at top-left.

%IMG7%magick ^
  %COLSIZ% ^
  ( xc:srgb(45%%,30%%,25%%) ^
    xc:srgb(75%%,60%%,50%%) ^
    +append +repage ^
  ) ^
  ( xc:srgb(90%%,50%%,20%%) ^
    xc:srgb(30%%,35%%,65%%) ^
    +append +repage ^
  ) ^
  -append +repage ^
  %SPLIC% ^
  %FIND0%

%IMG7%magick ^
  %COLSIZ% ^
  ( xc:srgb(50%%,50%%,70%%) ^
    xc:srgb(40%%,75%%,65%%) ^
    +append +repage ^
  ) ^
  ( xc:srgb(65%%,75%%,25%%) ^
    xc:srgb(90%%,65%%,15%%) ^
    +append +repage ^
  ) ^
  -append +repage ^
  %SPLIC% ^
  %FIND1%

%IMG7%magick ^
  %COLSIZ% ^
  ( xc:srgb(75%%,35%%,60%%) ^
    xc:srgb(0%%,55%%,65%%) ^
    +append +repage ^
  ) ^
  ( xc:srgb(35%%,35%%,35%%) ^
    xc:srgb(20%%,20%%,20%%) ^
    +append +repage ^
  ) ^
  -append +repage ^
  %SPLIC% ^
  %FIND2%

%IMG7%magick ^
  %COLSIZ% ^
  ( xc:srgb(15%%,25%%,60%%) ^
    xc:srgb(30%%,60%%,30%%) ^
    +append +repage ^
  ) ^
  ( xc:srgb(95%%,95%%,95%%) ^
    xc:srgb(80%%,80%%,80%%) ^
    +append +repage ^
  ) ^
  -append +repage ^
  %SPLIC% ^
  %FIND3%


set EXSTAT=

call %PICTBAT%24cardOneSize %1 %OUTFILE% %DEBUG% 500
if ERRORLEVEL 1 (
  set EXSTAT=1
) else if !OKAY24c!==1 (
  set EXSTAT=0
)

if "%EXSTAT%"=="" (
  call %PICTBAT%24cardOneSize %1 %OUTFILE% %DEBUG% 1000
  if ERRORLEVEL 1 (
    set EXSTAT=1
  ) else if !OKAY24c!==1 (
    set EXSTAT=0
  )
)

if "%EXSTAT%"=="" (
  call %PICTBAT%24cardOneSize %1 %OUTFILE% %DEBUG% 2000
  if ERRORLEVEL 1 (
    set EXSTAT=1
  ) else if !OKAY24c!==1 (
    set EXSTAT=0
  )
)

if "%EXSTAT%"=="" (
  call %PICTBAT%24cardOneSize %1 %OUTFILE% %DEBUG% 0
  if ERRORLEVEL 1 (
    set EXSTAT=1
  ) else if !OKAY24c!==1 (
    set EXSTAT=0
  )
)

if "%EXSTAT%"=="" set EXSTAT=1

if %EXSTAT%==1 (
  echo %0: no solution found
)

call echoRestore

endlocal & set OUTFILE24c=%OUTFILE%& set EXSTAT=%EXSTAT%& set sTRANS24c=%sTRANS24c%& set EIGHTNUM24c=%EIGHTNUM24c%

exit /B %EXSTAT%

24card4pt.bat

rem From image %1 that contains a 24-colour card,
rem make image files %2, must contain XX.
rem %3 if 1, debug mode.
rem %4 is one parameter, usually quoted, that contain 4 coordinate pairs,
rem   ie 8 numbers.


@if [%4]==[] findstr /B "rem @rem" %~f0 & exit /B 1

@setlocal enabledelayedexpansion

@call echoOffSave

call %PICTBAT%setInOut %1 24c

if not "%2"=="" if not "%2"=="." set OUTFILE=%2

set DEBUG=%3
if "%DEBUG%"=="." set DEBUG=
if "%DEBUG%"=="" set DEBUG=0

set FourPoints=%~4

echo %0: %INFILE% %OUTFILE% %DEBUG% [%FourPoints%]

set TMPDIR=\temp\

set OUTEXT=%OUTFILE:*.=%

set TMP_ROT=%OUTFILE:XX=tmprot%
set TMP_ROT=%TMPDIR%!TMP_ROT:%OUTEXT%=miff!

set TMP_ROT=%INFILE%

set OUT_MAT=%OUTFILE:XX=mat%
set OUT_SCL=%OUTFILE:XX=scl%
set OUT_DBG=%OUTFILE:XX=dbg%

echo %0: %TMP_ROT% %OUT_MAT% %OUT_SCL%


set Y3=
for /F "tokens=1-8 delims=, " %%A in ("%FourPoints%") do (
  echo %%A
  set X0=%%A
  set Y0=%%B
  set X1=%%C
  set Y1=%%D
  set X2=%%E
  set Y2=%%F
  set X3=%%G
  set Y3=%%H
)
if "%Y3%"=="" (
  echo %0: Need 8 numbers in [%FourPoints%]
  exit /B 1
)

call %PICTBAT%24cardChop

if ERRORLEVEL 1 (
  echo 24cardChop failed
  exit /B 1
)

call %PICTBAT%24cardDbg

if ERRORLEVEL 1 (
  echo 24cardDbg failed
  exit /B 1
)


call echoRestore

endlocal & set OUTFILE24c=%OUTFILE%& set OKAY24c=%OKAY%

24cardOneSize.bat

rem From image %1 that contains a 24-colour card,
rem make image files %2, must contain XX.
rem %3 if 1, debug mode.
rem %4 shrink size. 0 means no shrink. Default 1000.
rem 
@rem
@rem Updated:
@rem   22-August-2022 Upgraded for IM v7.
@rem

@if "%1"=="" findstr /B "rem @rem" %~f0 & exit /B 1

@setlocal enabledelayedexpansion

@call echoOffSave

call %PICTBAT%setInOut %1 24c

if not "%2"=="" if not "%2"=="." set OUTFILE=%2

set DEBUG=%3
if "%DEBUG%"=="." set DEBUG=
if "%DEBUG%"=="" set DEBUG=0

set SHR_SIZE=%4
if "%SHR_SIZE%"=="." set SHR_SIZE=
if "%SHR_SIZE%"=="" set SHR_SIZE=1000

:: FIXME: TRY_ROT is never set.
if "%TRY_ROT%"=="" set TRY_ROT=0


set LOGFILE=24card.log

echo %0: %1 %OUTFILE% %DEBUG% %SHR_SIZE%

set OUTEXT=%OUTFILE:*.=%

set OUT_MAT=%OUTFILE:XX=mat%
set OUT_SCL=%OUTFILE:XX=scl%
set OUT_DBG=%OUTFILE:XX=dbg%

if "%OUT_MAT%"=="%OUTFILE%" (
  echo %0: Outfile needs XX
  exit /B 1
)

del %OUT_MAT% 2>nul
del %OUT_SCL% 2>nul
del %OUT_DBG% 2>nul

set TMPDIR=\temp\

set TMP_IN=%OUTFILE:XX=tmpin%
set TMP_IN=%TMPDIR%!TMP_IN:%OUTEXT%=tiff!

set TMP_ROT=%OUTFILE:XX=tmprot%
set TMP_ROT=%TMPDIR%!TMP_ROT:%OUTEXT%=tiff!

echo %0: TMP_IN=%TMP_IN%  TMP_ROT=%TMP_ROT%



if "%COLS%"=="" set /A COLS=15

:: Resize down only.
::
if "%SHR_SIZE%"=="0" (
  set sRES=
) else (
  set sRES=-resize "%SHR_SIZE%^>"
)


%IMG7%magick ^
  %INFILE% ^
  -auto-orient ^
  %sRES% ^
  +depth ^
  +write %TMP_IN% ^
  %TMP_ROT%

set sTRANS=-auto-orient %sRES%

if "%TRY_ROT%"=="1" (
  call %PICTBAT%24cardRot %TMP_ROT% %FIND0%

  if ERRORLEVEL 1 exit /B 1

  set sROT=

  if not "!24crBEST_ANG!"==0 (
    set sROT=-virtual-pixel Black +distort SRT 1,!24crBEST_ANG! +repage
  )

  %IMG7%magick ^
    %TMP_ROT%
    !sROT! ^
    +depth ^
    +write %TMP_IN% ^
    %TMP_ROT%

  set sTRANS=%sTRANS% !sROT!
)

if ERRORLEVEL 1 (
  echo %0: %1 magick failed  >>%LOGFILE%
  exit /B 1
)

%IMG7%magick identify %TMP_ROT%




set SRCHPROC=-process 'rmsealpha ms md 15 adj 1.0 so'


set prev_ang=0

call :Find12 %TMP_ROT%
if ERRORLEVEL 1 exit /B 1

:: Crop to the card, plus a generous margin.

set FMT=^
diag=%%[fx:hypot((%Y0%-%Y1%),(%X1%-%X0%))]\n

echo %0: FMT=%FMT%

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

set FMT=^
diagOk=%%[fx:%diag%^>120]\n^
XL=%%[fx:floor((%X0%^<%X1%?%X0%:%X1%)-%diag%)]\n^
WH=%%[fx:floor(3*%diag%+2)]\n^
YT=%%[fx:floor((%Y0%^<%Y1%?%Y0%:%Y1%)-%diag%)]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

if %XL% LSS 0 set XL=0
if %YT% LSS 0 set YT=0

set sCROP=%WH%x%WH%+%XL%+%YT%

echo %0: diag=%diag%  diagOk=%diagOk% crop to %sCROP%

if %diagOk%==0 (
  set OKAY=0
  goto mkDebug
)

%IMG7%magick ^
  %TMP_ROT% ^
  -crop %sCROP% +repage ^
  +write %TMP_IN% ^
  %TMP_ROT%

set sTRANS=%sTRANS% -crop %sCROP% +repage


:: We have found top two points.
:: This gives an angle for rotation, so rotate it, and find other two points.

set CNT=3
:loop

call :Find12 %TMP_ROT%
if ERRORLEVEL 1 exit /B 1

set FMT=^
rotang=%%[fx:%prev_ang%+atan2((%Y0%-%Y1%),(%X1%-%X0%))*180/PI]\n

:: We accept +1 or -1 for dy.
set /A dy=%Y1%-%Y0%
if %dy% LSS 0 set /A dy=-%dy%

if %cnt% GTR 0 if %dy% GTR 1 (

  for /F "usebackq" %%L in (`%IMG7%magick identify ^
    -format "%FMT%" ^
    xc:`) do set %%L

  set FMT=^
angOkay=%%[fx:abs^(!rotang!^)^>45?0:1]\n

  for /F "usebackq" %%L in (`%IMG7%magick identify ^
    -format "!FMT!" ^
    xc:`) do set %%L

  echo %0: cnt=%cnt% rotang=!rotang! angOkay=!angOkay!

  if !angOkay!==0 (
    set OKAY=0
    goto mkDebug
  )

  %IMG7%magick ^
    %TMP_IN% ^
    -distort SRT 1,!rotang! +repage ^
    %TMP_ROT%

  set prev_ang=!rotang!

  set /A cnt-=1

  goto loop
)

if %cnt%==0 (
  echo %0: %1 magick failed >>%LOGFILE%
  echo %0: Loop bust: cnt==0.
  set OKAY=0
  goto mkDebug
)

if not %prev_ang%==0 (
  set sTRANS=%sTRANS% -distort SRT 1,%prev_ang% +repage 
)

echo Found X0,Y0 X1,Y1

for /F "usebackq tokens=1-4 delims=@, " %%A in (`%IM7DEV%magick ^
  %TMP_ROT% ^
  %FIND2% ^
  %SRCHPROC% ^
  NULL:`) do (
  if "%%A"=="rmsealpha:" (
    set score2=%%B
    set /A X2=%%C+%COLS%+%SEMI_SP%
    set /A Y2=%%D+%COLS%+%SEMI_SP%
  )
)

echo %0 2: %score2% %X2% %Y2%

for /F "usebackq tokens=1-4 delims=@, " %%A in (`%IM7DEV%magick ^
  %TMP_ROT% ^
  %FIND3%
  %SRCHPROC% ^
  NULL:`) do (
  if "%%A"=="rmsealpha:" (
    set score3=%%B
    set /A X3=%%C+%COLS%+%SEMI_SP%
    set /A Y3=%%D+%COLS%+%SEMI_SP%
  )
)

echo %0 3: %score3% %X3% %Y3%

set X0b=
set X0c=

set OKAY=1

if %X0% GTR %X1% set OKAY=0
if %X3% GTR %X2% set OKAY=0
if %Y0% GTR %Y3% set OKAY=0
if %Y1% GTR %Y2% set OKAY=0

if %OKAY%==0 (
  echo %0: Bad find.
  echo %0: %1 Bad find. >>%LOGFILE%
  set OKAY=0
  goto mkDebug
)

set /A dx=%X1%-%X0%
if %dx% LSS 90 (
  echo %0: Too small.
  echo %0: dx=%dx% Too small. Don't shrink so much?  >>%LOGFILE%
  set OKAY=0
  goto mkDebug
)

set EIGHTNUM=%X0%,%Y0% %X1%,%Y1% %X2%,%Y2% %X3%,%Y3%



call %PICTBAT%24cardChop

if ERRORLEVEL 1 (
  echo 24cardChop failed
  exit /B 1
)

goto skipChop
::--- Move following

:: Extrapolate outwards to get four corners.

set FMT=^
X0b=%%[fx:%X0%-(%X1%-%X0%)/4]\n^
X1b=%%[fx:%X1%+(%X1%-%X0%)/4]\n^
X3b=%%[fx:%X3%-(%X2%-%X3%)/4]\n^
X2b=%%[fx:%X2%+(%X2%-%X3%)/4]\n^
Y0b=%%[fx:%Y0%-(%Y1%-%Y0%)/4]\n^
Y3b=%%[fx:%Y3%-(%Y2%-%Y3%)/4]\n^
Y1b=%%[fx:%Y1%+(%Y1%-%Y0%)/4]\n^
Y2b=%%[fx:%Y2%+(%Y2%-%Y3%)/4]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

set FMT=^
X0c=%%[fx:%X0b%-(%X3b%-%X0b%)/2]\n^
X1c=%%[fx:%X1b%-(%X2b%-%X1b%)/2]\n^
X3c=%%[fx:%X3b%+(%X3b%-%X0b%)/2]\n^
X2c=%%[fx:%X2b%+(%X2b%-%X1b%)/2]\n^
Y0c=%%[fx:%Y0b%-(%Y3b%-%Y0b%)/2]\n^
Y3c=%%[fx:%Y3b%+(%Y3b%-%Y0b%)/2]\n^
Y1c=%%[fx:%Y1b%-(%Y2b%-%Y1b%)/2]\n^
Y2c=%%[fx:%Y2b%+(%Y2b%-%Y1b%)/2]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L


:: Distort so the four corners become a rectangle.

set FMT=^
left=%%[fx:(%X0c%+%X3c%)/2]\n^
top=%%[fx:(%Y0c%+%Y1c%)/2]\n^
right=%%[fx:(%X1c%+%X2c%)/2]\n^
bot=%%[fx:(%Y2c%+%Y3c%)/2]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

set PERSP_PARAMS=^
%X0c%,%Y0c%,%left%,%top%,^
%X1c%,%Y1c%,%right%,%top%,^
%X2c%,%Y2c%,%right%,%bot%,^
%X3c%,%Y3c%,%left%,%bot%


set FMT=^
LL=%%[fx:floor(%left%+0.5)]\n^
TT=%%[fx:floor(%top%+0.5)]\n^
WW=%%[fx:floor(%right%-%left%+0.5)]\n^
HH=%%[fx:floor(%bot%-%top%+0.5)]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

set OKAY=1

:: For deugging, we can record the calculated patches.

if "%DEBUG%" GTR "1" (
  set WR_CARD=+write %TMPDIR%24c_card.tiff
  set WR_PATHCHS=+write %TMPDIR%24c_x6.png
) else (
  set WR_CARD=
  set WR_PATHCHS=
)


echo %0: viewport [%WW%x%HH%+%LL%+%TT%]
echo %0: persp [%PERSP_PARAMS%]

set cnt=0
for /F "usebackq tokens=1,2" %%A in (`%IMG7%magick ^
  %TMP_ROT% ^
  -define "distort:viewport=%WW%x%HH%+%LL%+%TT%" ^
  -distort Perspective "%PERSP_PARAMS%" ^
  +repage ^
  %WR_CARD% ^
  -crop 6x4@ +repage ^
  -gravity Center ^
  -crop 75x75%%+0+0 +repage ^
  -format "SDOK %%[fx:standard_deviation<0.03?1:0]\n" +write info: ^
  +depth ^
  %WR_PATHCHS% ^
  -scale "1x1^!" ^
  +append +repage ^
  -crop 6x1 -append +repage ^
  +write %OUT_MAT% ^
  -scale "600x400^" ^
  +write %OUT_SCL% ^
  NULL:`) do (
  if "%%A"=="SDOK" if "%%B"=="0" (
    echo %0: cnt=!cnt! Bad patch
    set OKAY=0
  )
  set /A cnt+=1
)

echo %0: Created %OUT_MAT% and %OUT_SCL%

::--- End Move following
:skipChop



:mkDebug


call %PICTBAT%24cardDbg

if ERRORLEVEL 1 (
  echo 24cardDbg failed
  exit /B 1
)

goto skipDbg
::--- Move following

if %OKAY%==0 set DEBUG=1

set CIRC=circle 0,0 0,10

del %OUT_DBG% 2>nul

if "%DEBUG%" GTR "0" (

  set CIRCS1b=
  set CIRCS2=
  set CIRCS3=

  if not "%X0b%"=="" (
    set CIRCS2=^
    -draw "translate %X0b%,%Y0b% %CIRC%" ^
    -draw "translate %X1b%,%Y1b% %CIRC%" ^
    -draw "translate %X2b%,%Y2b% %CIRC%" ^
    -draw "translate %X3b%,%Y3b% %CIRC%"
  )

  if not "%X0c%"=="" (
    set CIRCS3=^
    -draw "translate %X0c%,%Y0c% %CIRC%" ^
    -draw "translate %X1c%,%Y1c% %CIRC%" ^
    -draw "translate %X2c%,%Y2c% %CIRC%" ^
    -draw "translate %X3c%,%Y3c% %CIRC%"
  )

  if not "%X2%"=="" (
    set CIRCS1b=^
    -draw "translate %X2%,%Y2% %CIRC%" ^
    -draw "translate %X3%,%Y3% %CIRC%"
  )

  %IMG7%magick ^
    %TMP_ROT% ^
    -fill None ^
    -stroke Orange ^
    -draw "translate %X0%,%Y0% %CIRC%" ^
    -draw "translate %X1%,%Y1% %CIRC%" ^
    !CIRCS1b! ^
    -stroke Cyan !CIRCS2! ^
    -stroke Lime !CIRCS3! ^
    +depth ^
    %OUT_DBG%

  echo %0: Created %OUT_DBG%
)

::--- End Move following
:skipDbg


call echoRestore

echo %0: OKAY=%OKAY%

endlocal & set OUTFILE24c=%OUTFILE%& set OKAY24c=%OKAY%& set sTRANS24c=%sTRANS%& set EIGHTNUM24c=%EIGHTNUM%

@exit /B 0

::---------------------------------------------------
:: Subroutines

:Find12

set SUB_IN=%1

set X0=
for /F "usebackq tokens=1-4 delims=@, " %%A in (`%IM7DEV%magick ^
  %SUB_IN% ^
  %FIND0% ^
  %SRCHPROC% ^
  NULL:`) do (
  if "%%A"=="rmsealpha:" (
    set score0=%%B
    set /A X0=%%C+%COLS%+%SEMI_SP%
    set /A Y0=%%D+%COLS%+%SEMI_SP%
  )
)

echo %0 0: score=%score0% X0=%X0% Y0=%Y0%

if "%X0%"=="" exit /B 1

set X1=
for /F "usebackq tokens=1-4 delims=@, " %%A in (`%IM7DEV%magick ^
  %SUB_IN% ^
  %FIND1% ^
  %SRCHPROC% ^
  NULL:`) do (
  if "%%A"=="rmsealpha:" (
    set score1=%%B
    set /A X1=%%C+%COLS%+%SEMI_SP%
    set /A Y1=%%D+%COLS%+%SEMI_SP%
  )
)

echo %0 1: score=%score1% X1=%X1% Y1=%Y1%

if "%X1%"=="" exit /B 1


exit /B 0

24cardChop.bat

rem Don't call this directly.
@rem
@rem Updated:
@rem   22-August-2022 Upgraded for IM v7.
@rem

if "%TMPDIR%"=="" exit /B 1



:: Extrapolate outwards to get four corners.

set FMT=^
X0b=%%[fx:%X0%-(%X1%-%X0%)/4]\n^
X1b=%%[fx:%X1%+(%X1%-%X0%)/4]\n^
X3b=%%[fx:%X3%-(%X2%-%X3%)/4]\n^
X2b=%%[fx:%X2%+(%X2%-%X3%)/4]\n^
Y0b=%%[fx:%Y0%-(%Y1%-%Y0%)/4]\n^
Y3b=%%[fx:%Y3%-(%Y2%-%Y3%)/4]\n^
Y1b=%%[fx:%Y1%+(%Y1%-%Y0%)/4]\n^
Y2b=%%[fx:%Y2%+(%Y2%-%Y3%)/4]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

set FMT=^
X0c=%%[fx:%X0b%-(%X3b%-%X0b%)/2]\n^
X1c=%%[fx:%X1b%-(%X2b%-%X1b%)/2]\n^
X3c=%%[fx:%X3b%+(%X3b%-%X0b%)/2]\n^
X2c=%%[fx:%X2b%+(%X2b%-%X1b%)/2]\n^
Y0c=%%[fx:%Y0b%-(%Y3b%-%Y0b%)/2]\n^
Y3c=%%[fx:%Y3b%+(%Y3b%-%Y0b%)/2]\n^
Y1c=%%[fx:%Y1b%-(%Y2b%-%Y1b%)/2]\n^
Y2c=%%[fx:%Y2b%+(%Y2b%-%Y1b%)/2]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L


:: Distort so the four corners become a rectangle.

set FMT=^
left=%%[fx:(%X0c%+%X3c%)/2]\n^
top=%%[fx:(%Y0c%+%Y1c%)/2]\n^
right=%%[fx:(%X1c%+%X2c%)/2]\n^
bot=%%[fx:(%Y2c%+%Y3c%)/2]\n

set FMT=^
left=%%[fx:min(min(min(%X0c%,%X1c%),%X2c%),%X3c%)]\n^
top=%%[fx:min(min(min(%Y0c%,%Y1c%),%Y2c%),%Y3c%)]\n^
right=%%[fx:max(max(max(%X0c%,%X1c%),%X2c%),%X3c%)]\n^
bot=%%[fx:max(max(max(%Y0c%,%Y1c%),%Y2c%),%Y3c%)]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

set PERSP_PARAMS=^
%X0c%,%Y0c%,%left%,%top%,^
%X1c%,%Y1c%,%right%,%top%,^
%X2c%,%Y2c%,%right%,%bot%,^
%X3c%,%Y3c%,%left%,%bot%


set FMT=^
LL=%%[fx:floor(%left%+0.5)]\n^
TT=%%[fx:floor(%top%+0.5)]\n^
WW=%%[fx:floor(%right%-%left%+0.5)]\n^
HH=%%[fx:floor(%bot%-%top%+0.5)]\n

for /F "usebackq" %%L in (`%IMG7%magick identify ^
  -format "%FMT%" ^
  xc:`) do set %%L

set OKAY=1

:: For deugging, we can record the calculated patches.

if "%DEBUG%" GTR "1" (
  set WR_CARD=+write %TMPDIR%24c_card.tiff
  set WR_PATHCHS=+write %TMPDIR%24c_x6.png
) else (
  set WR_CARD=
  set WR_PATHCHS=
)


echo %0: viewport [%WW%x%HH%+%LL%+%TT%]
echo %0: persp [%PERSP_PARAMS%]

set cnt=0
for /F "usebackq tokens=1,2" %%A in (`%IMG7%magick ^
  %TMP_ROT% ^
  -define "distort:viewport=%WW%x%HH%+%LL%+%TT%" ^
  -distort Perspective "%PERSP_PARAMS%" ^
  +repage ^
  %WR_CARD% ^
  -crop 6x4@ +repage ^
  -gravity Center ^
  -crop 75x75%%+0+0 +repage ^
  -format "SDOK %%[fx:standard_deviation<0.03?1:0]\n" +write info: ^
  +depth ^
  %WR_PATHCHS% ^
  -scale "1x1^!" ^
  +append +repage ^
  -crop 6x1 +repage -append +repage ^
  +write %OUT_MAT% ^
  -scale "600x400^" ^
  +write %OUT_SCL% ^
  NULL:`) do (
  if "%%A"=="SDOK" if "%%B"=="0" (
    echo %0: cnt=!cnt! Bad patch
    set OKAY=0
  )
  set /A cnt+=1
)

if not %cnt%==24 (
  echo %0: didn't find 24 patches.
  exit /B 1
)

echo %0: Created %OUT_MAT% and %OUT_SCL%

24cardDbg.bat

rem Don't call this directly.
@rem
@rem Updated:
@rem   22-August-2022 Upgraded for IM v7.
@rem

if "%OUT_DBG%"=="" exit /B 1


if %OKAY%==0 set DEBUG=1

set CIRC=circle 0,0 0,10

del %OUT_DBG% 2>nul

if "%DEBUG%" GTR "0" (

  set CIRCS1b=
  set CIRCS2=
  set CIRCS3=

  if not "%X0b%"=="" (
    set CIRCS2=^
    -draw "translate %X0b%,%Y0b% %CIRC%" ^
    -draw "translate %X1b%,%Y1b% %CIRC%" ^
    -draw "translate %X2b%,%Y2b% %CIRC%" ^
    -draw "translate %X3b%,%Y3b% %CIRC%"
  )

  if not "%X0c%"=="" (
    set CIRCS3=^
    -draw "translate %X0c%,%Y0c% %CIRC%" ^
    -draw "translate %X1c%,%Y1c% %CIRC%" ^
    -draw "translate %X2c%,%Y2c% %CIRC%" ^
    -draw "translate %X3c%,%Y3c% %CIRC%"
  )

  if not "%X2%"=="" (
    set CIRCS1b=^
    -draw "translate %X2%,%Y2% %CIRC%" ^
    -draw "translate %X3%,%Y3% %CIRC%"
  )

  %IMG7%magick ^
    %TMP_ROT% ^
    -fill None ^
    -stroke Orange ^
    -draw "translate %X0%,%Y0% %CIRC%" ^
    -draw "translate %X1%,%Y1% %CIRC%" ^
    !CIRCS1b! ^
    -stroke Cyan !CIRCS2! ^
    -stroke Lime !CIRCS3! ^
    +depth ^
    %OUT_DBG%

  echo %0: Created %OUT_DBG%
)

bottomLineGray.bat

@%IMG7%magick ^
  %1 ^
  -gravity south -crop x1+0+0 +repage ^
  ( +clone -colorspace Gray ) ^
  -metric RMSE -format %%[distortion] -compare ^
  info:

All images on this page were created by the commands shown, using:

%IMG7%magick -version
Version: ImageMagick 7.1.1-15 Q16-HDRI x64 a0a5f3d:20230730 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: https://imagemagick.org/script/license.php
Features: Cipher DPC HDRI OpenCL OpenMP(2.0) 
Delegates (built-in): bzlib cairo freetype gslib heic jng jp2 jpeg jxl lcms lqr lzma openexr pangocairo png ps raqm raw rsvg tiff webp xml zip zlib
Compiler: Visual Studio 2022 (193532217)
%IM7DEV%magick -version
Version: ImageMagick 7.1.1-13 (Beta) Q32-HDRI x86_64 a8de149e1:20230703 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: https://imagemagick.org/script/license.php
Features: Cipher DPC HDRI Modules OpenCL OpenMP(4.5) 
Delegates (built-in): bzlib cairo fftw fontconfig freetype heic jbig jng jpeg lcms ltdl lzma pangocairo png raqm raw rsvg tiff webp wmf x xml zip zlib
Compiler: gcc (11.3)

Source file for this web page is findcard.h1. To re-create this web page, execute "procH1 findcard".


This page, including the images, is my copyright. Anyone is permitted to use or adapt any of the code, scripts or images for any purpose, including commercial use.

Anyone is permitted to re-publish this page, but only for non-commercial use.

Anyone is permitted to link to this page, including for commercial use.


Page version v1.1 1-Nov-2017.

Page created 29-Sep-2023 06:41:09.

Copyright © 2023 Alan Gibson.