{
  Deskew
  by Marek Mauder
  http://galfar.vevb.net/deskew

  The contents of this file are used with permission, subject to the Mozilla
  Public License Version 1.1 (the "License"); you may not use this file except
  in compliance with the License. You may obtain a copy of the License at
  http://www.mozilla.org/MPL/MPL-1.1.html

  Software distributed under the License is distributed on an "AS IS" basis,
  WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
  the specific language governing rights and limitations under the License.

  Alternatively, the contents of this file may be used under the terms of the
  GNU Lesser General Public License (the  "LGPL License"), in which case the
  provisions of the LGPL License are applicable instead of those above.
  If you wish to allow use of your version of this file only under the terms
  of the LGPL License and not to allow others to use your version of this file
  under the MPL, indicate your decision by deleting  the provisions above and
  replace  them with the notice and other provisions required by the LGPL
  License.  If you do not delete the provisions above, a recipient may use
  your version of this file under either the MPL or the LGPL License.

  For more information about the LGPL: http://www.gnu.org/copyleft/lesser.html
}

unit MainUnit;

{$I ImagingOptions.inc}

interface

procedure RunDeskew;

implementation

uses
  Types,
  SysUtils,
  Classes,
  ImagingTypes,
  Imaging,
  ImagingClasses,
  ImagingFormats,
  ImagingUtility,
  ImagingExtras,
  // Project units
  CmdLineOptions,
  ImageUtils,
  RotationDetector;

const
  SAppTitle = 'Deskew 1.30 (2019-06-07)'
    {$IF Defined(CPUX64)} + ' x64'
    {$ELSEIF Defined(CPUX86)} + ' x86'
    {$ELSEIF Defined(CPUARM)} + ' ARM'
    {$IFEND}
    {$IFDEF DEBUG} + ' (DEBUG)'{$ENDIF}
    + ' by Marek Mauder';
  SAppHome = 'http://galfar.vevb.net/deskew/';

var
  // Program options
  Options: TCmdLineOptions;
  // Input and output image
  InputImage, OutputImage: TSingleImage;

procedure WriteUsage;
var
  InFilter, OutFilter: string;
  I, Count: Integer;
  Fmt: TImageFileFormat;
begin
  InFilter := '';
  OutFilter := '';

  WriteLn('Usage:');
  WriteLn('deskew [-o output] [-a angle] [-b color] [..] input');
  WriteLn('    input:         Input image file');
  WriteLn('  Options:');
  WriteLn('    -o output:     Output image file (default: out.png)');
  WriteLn('    -a angle:      Maximal expected skew angle (both directions) in degrees (default: 10)');
  WriteLn('    -b color:      Background color in hex format RRGGBB|LL|AARRGGBB (default: black)');
  WriteLn('  Ext. options:');
  WriteLn('    -q filter:     Resampling filter used for rotations (default: linear,');
  WriteLn('                   values: nearest|linear|cubic|lanczos)');
  WriteLn('    -t a|treshold: Auto threshold or value in 0..255 (default: a)');
  WriteLn('    -r rect:       Skew detection only in content rectangle (pixels):');
  WriteLn('                   left,top,right,bottom (default: whole page)');
  WriteLn('    -f format:     Force output pixel format (values: b1|g8|rgb24|rgba32)');
  WriteLn('    -l angle:      Skip deskewing step if skew angle is smaller (default: 0.01)');
  WriteLn('    -g flags:      Operational flags (any combination of):');
  WriteLn('                   c - auto crop, d - detect only (no output to file)');
  WriteLn('    -s info:       Info dump (any combination of):');
  WriteLn('                   s - skew detection stats, p - program parameters, t - timings');
  WriteLn('    -c specs:      Output compression specs for some file formats. Several specs');
  WriteLn('                   can be defined - delimited by commas. Supported specs:');
  WriteLn('                   jXX - JPEG compression quality, XX is in range [1,100(best)]');
  WriteLn('                   tSCHEME - TIFF compression scheme: none|lzw|rle|deflate|jpeg|g4');


  Count := GetFileFormatCount;
  for I := 0 to Count - 1 do
  begin
    Fmt := GetFileFormatAtIndex(I);
    if Fmt.CanLoad then
      InFilter := InFilter + Fmt.Extensions[0] + Iff(I < Count - 1, ', ', '');
    if Fmt.CanSave then
      OutFilter := OutFilter + Fmt.Extensions[0] + Iff(I < Count - 1, ', ', '');
  end;

  WriteLn;
  WriteLn('  Supported file formats');
  WriteLn('    Input:  ', UpperCase(InFilter));
  WriteLn('    Output: ', UpperCase(OutFilter));
end;

procedure ReportBadInput(const Msg: string; ShowUsage: Boolean = True);
begin
  WriteLn;
  WriteLn('Error: ' + Msg);
  if Options.ErrorMessage <> '' then
    WriteLn(Options.ErrorMessage);
  WriteLn;

  if ShowUsage then
    WriteUsage;

  ExitCode := 1;
end;

function FormatNiceNumber(const X: Int64; Width : Integer = 16): string;
var
  FmtStr: string;
begin
  if Width = 0 then
    FmtStr := '%.0n'
  else
    FmtStr := '%' + IntToStr(Width) + '.0n';
  Result := Format(FmtStr, [X * 1.0], GetFormatSettingsForFloats);
end;

var
  Time: Int64;

procedure WriteTiming(const StepName: string);
begin
  if Options.ShowTimings then
    WriteLn(StepName + ' - time taken: ' + FormatNiceNumber(GetTimeMicroseconds - Time, 0) + ' us');
end;

function DoDeskew: Boolean;
var
  SkewAngle: Double;
  Threshold: Integer;
  ContentRect: TRect;
  Stats: TCalcSkewAngleStats;

  procedure WriteStats;
  begin
    WriteLn('Skew detection stats:');
    WriteLn('  pixel count:        ', FormatNiceNumber(Stats.PixelCount));
    WriteLn('  tested pixels:      ', FormatNiceNumber(Stats.TestedPixels));
    WriteLn('  accumulator size:   ', FormatNiceNumber(Stats.AccumulatorSize));
    WriteLn('  accumulated counts: ', FormatNiceNumber(Stats.AccumulatedCounts));
    WriteLn('  best count:         ', FormatNiceNumber(Stats.BestCount));
  end;

begin
  Result := False;
  Threshold := 0;
  WriteLn('Preparing input image (', ExtractFileName(Options.InputFile), ' [',
    InputImage.Width, 'x', InputImage.Height, '/', string(InputImage.FormatInfo.Name), ']) ...');

  // Clone input image and convert it to 8bit grayscale. This will be our
  // working image.
  OutputImage.Assign(InputImage);
  InputImage.Format := ifGray8;

  // Determine threshold level for black/white pixel classification during skew detection
  case Options.ThresholdingMethod of
    tmExplicit:
      begin
        // Use explicit threshold
        Threshold := Options.ThresholdLevel;
      end;
    tmOtsu:
      begin
        // Determine the threshold automatically
        Time := GetTimeMicroseconds;
        Threshold := OtsuThresholding(InputImage.ImageDataPointer^);
        WriteTiming('Auto thresholding');
      end;
  end;

  // Determine the content rect - where exactly to detect rotated text
  ContentRect := InputImage.BoundsRect;
  if not IsRectEmpty(Options.ContentRect) then
  begin
    if not IntersectRect(ContentRect, Options.ContentRect, InputImage.BoundsRect) then
      ContentRect := InputImage.BoundsRect;
  end;

  // Main step - calculate image rotation SkewAngle
  WriteLn('Calculating skew angle...');
  Time := GetTimeMicroseconds;
  SkewAngle := CalcRotationAngle(Options.MaxAngle, Threshold,
    InputImage.Width, InputImage.Height, InputImage.Bits,
    @ContentRect, @Stats);
  WriteTiming('Skew detection');
  WriteLn('Skew angle found [deg]: ', SkewAngle:4:3);

  if Options.ShowStats then
    WriteStats;

  if ofDetectOnly in Options.OperationalFlags then
    Exit;

  // Check if detected skew angle is higher than "skip" threshold - may not
  // want to do rotation needlessly.
  if Abs(SkewAngle) >= Options.SkipAngle then
  begin
    Result := True;

    // Finally, rotate the image. We rotate the original input image, not the working
    // one so the color space is preserved if possible.
    WriteLn('Rotating image...');

    // Rotation is optimized for Gray8, RGB24, and ARGB32 formats at this time
    if not (OutputImage.Format in ImageUtils.SupportedRotationFormats) then
    begin
      if OutputImage.Format = ifIndex8 then
      begin
        if PaletteHasAlpha(OutputImage.Palette, OutputImage.PaletteEntries) then
          OutputImage.Format := ifA8R8G8B8
        else if PaletteIsGrayScale(OutputImage.Palette, OutputImage.PaletteEntries) then
          OutputImage.Format := ifGray8
        else
          OutputImage.Format := ifR8G8B8;
      end
      else if OutputImage.FormatInfo.HasAlphaChannel then
        OutputImage.Format := ifA8R8G8B8
      else if (OutputImage.Format = ifBinary) or OutputImage.FormatInfo.HasGrayChannel then
        OutputImage.Format := ifGray8
      else
        OutputImage.Format := ifR8G8B8;
    end;

    if (Options.BackgroundColor and $FF000000) <> $FF000000 then
    begin
      // User explicitly requested some alpha in background color
      OutputImage.Format := ifA8R8G8B8;
    end
    else if (OutputImage.Format = ifGray8) and not (
      (GetRedValue(Options.BackgroundColor) = GetGreenValue(Options.BackgroundColor)) and
      (GetBlueValue(Options.BackgroundColor) = GetGreenValue(Options.BackgroundColor))) then
    begin
      // Some non-grayscale background for gray image was requested
      OutputImage.Format := ifR8G8B8;
    end;

    Time := GetTimeMicroseconds;
    ImageUtils.RotateImage(OutputImage.ImageDataPointer^, SkewAngle, Options.BackgroundColor,
      Options.ResamplingFilter, not (ofAutoCrop in Options.OperationalFlags));
    WriteTiming('Rotate image');
  end
  else
    WriteLn('Skipping deskewing step, skew angle lower than threshold of ', Options.SkipAngle:4:2);

  if (Options.ForcedOutputFormat <> ifUnknown) and (OutputImage.Format <> Options.ForcedOutputFormat) then
  begin
    // Force output format. For example Deskew won't automatically
    // save image as binary if the input was binary since it
    // might degrade the output a lot (rotation adds a lot of colors to image).
    OutputImage.Format := Options.ForcedOutputFormat;
    Result := True;
  end;
end;

procedure RunDeskew;

  procedure EnsureOutputLocation(const FileName: string);
  var
    Dir, Path: string;
  begin
    Path := ExpandFileName(FileName);
    Dir := GetFileDir(Path);
    if Dir <> '' then
      ForceDirectories(Dir);
  end;

  procedure CopyFile(const SrcPath, DestPath: string);
  var
    SrcStream, DestStream: TFileStream;
  begin
    if SameText(SrcPath, DestPath) then
      Exit; // No need to copy anything

    SrcStream := TFileStream.Create(SrcPath, fmOpenRead);
    DestStream := TFileStream.Create(DestPath, fmCreate);
    DestStream.CopyFrom(SrcStream, SrcStream.Size);
    DestStream.Free;
    SrcStream.Free;
  end;

  procedure SetImagingOptions;
  begin
    if Options.JpegCompressionQuality <> -1 then
    begin
      Imaging.SetOption(ImagingJpegQuality, Options.JpegCompressionQuality);
      Imaging.SetOption(ImagingTiffJpegQuality, Options.JpegCompressionQuality);
      Imaging.SetOption(ImagingJNGQuality, Options.JpegCompressionQuality);
    end;
    if Options.TiffCompressionScheme <> -1 then
      Imaging.SetOption(ImagingTiffCompression, Options.TiffCompressionScheme);
  end;

var
  Changed: Boolean;
begin
{$IF Defined(FPC) and not Defined(MSWINDOWS)}
  // Flush after WriteLn also when output is redirected to file/pipe
  if Textrec(Output).FlushFunc = nil then
    Textrec(Output).FlushFunc := Textrec(Output).InOutFunc;
{$IFEND}

  WriteLn(SAppTitle);
  WriteLn(SAppHome);

  Options := TCmdLineOptions.Create;
  InputImage := TSingleImage.Create;
  OutputImage := TSingleImage.Create;

  try
    try
      if Options.ParseCommnadLine and Options.IsValid then
      begin
        SetImagingOptions;
        if Options.ShowParams then
          WriteLn(Options.OptionsToString);

        if not IsFileFormatSupported(Options.InputFile) then
        begin
          ReportBadInput('File format not supported: ' + Options.InputFile);
          Exit;
        end;

        // Load input image
        Time := GetTimeMicroseconds;
        InputImage.LoadFromFile(Options.InputFile);
        WriteTiming('Load input file');

        if not InputImage.Valid then
        begin
          ReportBadInput('Loaded input image is not valid: ' + Options.InputFile, False);
          Exit;
        end;

        // Do the magic
        Changed := DoDeskew();

        if not (ofDetectOnly in Options.OperationalFlags) then
        begin
          WriteLn('Saving output (', ExpandFileName(Options.OutputFile), ' [',
            OutputImage.Width, 'x', OutputImage.Height, '/', string(OutputImage.FormatInfo.Name), ']) ...');

          // Make sure output folders are ready
          EnsureOutputLocation(Options.OutputFile);
          // In case no change to image was done by deskewing we still need to resave if requested file format differs from input
          Changed := Changed or not SameText(GetFileExt(Options.InputFile), GetFileExt(Options.OutputFile));

          Time := GetTimeMicroseconds;
          if Changed then
          begin
            // Make sure recognized metadata stays (like scanning DPI info)
            GlobalMetadata.CopyLoadedMetaItemsForSaving;
            // Save the output
            OutputImage.SaveToFile(Options.OutputFile);
          end
          else
          begin
            // No change to image made, just copy it to the desired destination
            CopyFile(Options.InputFile, Options.OutputFile);
          end;
          WriteTiming('Save output file');
        end;

        WriteLn('Done!');
      end
      else
      begin
        // Bad input
        ReportBadInput('Invalid parameters!');
      end;

    except
      on E: Exception do
      begin
        WriteLn;
        WriteLn(E.ClassName, ': ', E.Message);
        ExitCode := 1;
      end;
    end;
  finally
    Options.Free;
    InputImage.Free;
    OutputImage.Free;

{$IFDEF DEBUG}
  ReadLn;
{$ENDIF}
  end;
end;

end.
