65.9K
CodeProject 正在变化。 阅读更多。
Home

给作弊者的变形绘制

2019年3月4日

CPOL

13分钟阅读

viewsIcon

19334

downloadIcon

296

使用这种易于使用的方法和软件给朋友留下深刻印象

 

Anamorphic Drawing

 

引言

绘画是一种幻觉,一种魔法,所以你看到的并非你所见。

菲利普·加斯顿

目录

变形绘图

上面的图片中显示的是什么?不,这不是一只玩具马站在一张纸上。也不是玩具马的照片。

钢笔揭示了真相:它平放在一张桌子上的纸上;同时,马匹出现在背景中。这看起来是不可能的。

事实上,只有两个物体被放在桌子的玻璃顶上:一张平整的纸,以及放在纸上的笔。这张照片显示了这张桌子和上面的这两个物体。在这张纸上,有一匹马的绘画;这张纸被玩具马图像的轮廓切割开。这张照片是从一个特殊的视角拍摄的,创造了马匹垂直站立的幻觉。它看起来像是从绘画平面“凸出来”一样。当然,为了支撑这种幻觉,这幅画以一种特殊的方式被扭曲了。

这是变形艺术的一种形式;在YouTube上有很多有趣的例子

当然,任何人都可以接受训练并学会绘制这类图像。然而,这并不容易,而且比传统绘画方式要困难得多。如果我展示一种非常简单便捷的作弊方法,即使是绘画技能略有欠缺的人也能创造出这样的奇迹,您觉得怎么样?

怎么做?使用这个应用程序

About

洞察

想法很简单。假设我们在桌子上放一张矩形纸,并拍摄它,由于透视效果,这张纸会被拍成一个对称的梯形。在上面放上或悬挂某个物体。想象一下,之后一张图片将被放在同一个位置。它可以是同一个物体和同一张纸的图像,经过相应的变形,以产生物体垂直放置的印象,就像在原始场景中一样。

让我们思考一下,照片应该如何变换?显然,如果我们把同一张纸垂直放置,它将被拍成一个矩形区域,其长宽比与原始纸张相同。同样,我们可以使用以下标准来变换图片:代表纸张图像的梯形应该被变换成一个矩形,其长宽比与原始纸张相同。换句话说,它应该是一个精确的“反透视”变换。

这是第二个想法:如何将所有必要的传输参数信息传递给软件应用程序?

首先,我们要求用户拍摄一张照片,其长宽比与原始纸张的长宽比相同。此外,我们需要四个点的坐标,这些点定义了照片中纸张图像的位置和形状。用户必须在这些点上放置标记,即梯形的四个角。这就足够了。

如何创造幻觉?

因此,用户需要拍摄一张照片,其中参考纸张图像的形状为对称梯形,并标记其角点,同时确保该纸张和照片的长宽比值相同。这可以通过以下两种方式之一或其组合来完成:a) 纸张应裁剪为画框的长宽比;b) 拍照时,可以裁剪为原始纸张的长宽比。通常,第二种方法最简单。当图片加载到软件中时,其长宽比会包含一些关于场景的重要信息。

上述规则是近似的。在以下条件下,它们可以得到良好的近似:

  • 镜头的有效焦距不宜过短;实际上,100毫米(相当于35毫米)或更高。
  • 镜头应相对直线且畸变较低;实际上,任何价格合理的可更换镜头或嵌入式变焦镜头都能很好地工作。
  • 景深应能充分覆盖纸张的大小和所有感兴趣的物体。
  • 场景的地平线不宜过低。换句话说,透视效果应该明显但适中。实际上,纸张近端和远端水平边的角度大小可能相差一倍,但很难有更大的差异。
  • 所有感兴趣的物体角度大小应与纸张的角度大小相当;换句话说,纸张应占据场景的很大一部分。

长宽比值会传递重要信息,软件将使用这些信息执行精确的图像变换。剩余部分可以通过软件的用户界面传递:用户需要指出代表纸张的梯形的所有四个角的点。 注:“所有”应为“各个”。

最后,最终图像应该从与原始场景完全相同的点拍摄。这将为幻觉提供最佳的视角。因此,使用三脚架是最佳选择。现在,让我们看看如何执行所有步骤,包括使用软件。

工作流

在某些矩形纸上拍摄物体。确保纸张的顶部和底部边缘与画框的下边界平行。透视下的纸张图像应对称:[photograph]。此要求是近似的。使用三脚架。拍照时,不要移动三脚架,并标记纸张的位置。在拍摄结果时,您将需要相同的位置。

确保您的图片长宽比与拍摄的纸张长宽比相同。如果需要,请使用任何合适的图像编辑器将图片裁剪到所需的长宽比。

使用AnamorphicDrawing.exe加载您的源图片

Load

通过放置相应的角抓手来标记纸张图像的四个角。您可以从左上角开始顺时针点击每个角,快速开始。不用担心准确性:稍后可以进行修正。

Mark corners

使用 Tab 键或鼠标激活每个角抓手,并使用箭头键精确调整它们的位置。

Adjust corners

抓手位置的调整可以快速进行,但精度为一屏幕像素 — 因此最好在最大化的主应用程序窗口中进行操作。运动有四个级别;请参阅用户界面文档中的详细说明。

点击“下一步”以获得变形图像。纸张被变换成看起来像矩形,长宽比与源图像相同。

Transform

请注意,苹果的形状不仅被拉长了,顶部也变宽了。这是自然的:正常的透视投影会使图像透视缩短,远处的物体尺寸减小。难怪,我们的反透视变换应该做相反的事情。

移动四个裁剪抓手来定义裁剪区域

Define crop

现在定义了裁剪区域,它匹配纸张的边缘。在这张图片中,可以看出变换后的图像并非严格矩形。这是由于光学畸变造成的。

点击“下一步”

Crop

现在变形图像已被裁剪和缩放。您可以将其保存,或选择立即打印。

Save and print

为了打印,图像应该被缩放,以近似匹配原始纸张的大小。变形图像比这张纸要高得多。

为了获得良好效果,最好保持透视适中;否则,变形图像会变得太高,从而导致高畸变。

打印出的图像可以直接使用。或者,相同的图像可以被处理以获得图像轮廓,这些轮廓可以非常浅,作为手动绘制的方便参考。第一个全彩图像可以用作参考,作为绘制的模型。

纸上的图像已准备好。然后需要将其剪裁,以去除背景区域。在上图所示的图片中,这是棕色区域。

变形图像现在可以被拍摄了。假设三脚架仍处于相同位置,并且镜头的变焦级别与最初相同,我们可以将打印结果放置在与原始图像相同的位置和方向。

实现

透视变换

软件的核心是某种图像变换,它不是仿射变换。仿射变换在典型的图像处理库中不易获得。仿射函数仿射空间之间保持点、直线和平面,就像我们需要的透视变换一样。另一方面,如果一个变换是仿射的,一组平行线在变换后仍然平行,而这种情况并非如此。

这种变换的一个例子如上所示,是变换前的图像(左图)变换后的图像(右图)之间的过渡。
在这里,棕色矩形代表整个图像被变换成梯形。纸张在苹果物体下的图像从梯形变换成矩形(具有一定的精度),这显然只适用于某些特殊形状。

我最终实现了自己的像素级 WPF `System.Windows.Media.Imaging` 变换函数,仅用于特定的特殊情况。在这种情况下,我们假设纸张(浅色)的图像是严格对称的;梯形的底边与照片的下边缘平行。在这种情况下,只需对称地重新采样原始图像的每个水平像素跨度即可。假设我们为照片的上边缘和下边缘设置了单独的重采样比例值。中间的所有跨度都应具有在上下比例之间线性插值得到的重采样比例。

namespace AnamorphicDrawing.Main {
    using System;
    using System.Windows;
    using System.Windows.Media.Imaging;

    class PerspectiveTransform {

        internal PerspectiveTransform(
            double horizontalScaleFrom,
            double horizontalScaleTo)
        {
            this.horizontalScaleFrom = horizontalScaleFrom;
            this.horizontalScaleTo = horizontalScaleTo;
            this.maxHorizontalScale =
                Math.Max(horizontalScaleFrom, horizontalScaleTo);
        } //PerspectiveTransform

        internal BitmapSource Apply(BitmapSource source) {
            int destinationWidth =
                (int)Math.Round(maxHorizontalScale * source.PixelWidth);
            WriteableBitmap destination = new WriteableBitmap(
                destinationWidth, source.PixelHeight,
                source.DpiX, source.DpiY, source.Format, source.Palette);
            int bytesPerPixel =
                destination.BackBufferStride / destinationWidth;
            int sourceStride = bytesPerPixel * source.PixelWidth;
            int destinationStride = bytesPerPixel * destinationWidth;
            byte[] sourceArray = new byte[sourceStride];
            byte[] destinationArray = new byte[destinationStride];
            byte fillByte = (source.Format.Masks.Count == 4) ?
                (byte)0 : (byte)0xff;
            double factor = (horizontalScaleTo - horizontalScaleFrom) /
                source.PixelHeight;
            if (UseInterpolation == Interpolation.Linear)
                LinearInterpolation(
                    factor, bytesPerPixel, source, destination,
                    destinationWidth, sourceArray, destinationArray,
                    sourceStride, destinationStride, fillByte);
            else
                NearestNeighborInterpolation(
                    factor, bytesPerPixel, source, destination,
                    destinationWidth, sourceArray, destinationArray,
                    sourceStride, destinationStride, fillByte);
            return destination;
        } //Apply

        void NearestNeighborInterpolation(...) {
            // ...
        } //NearestNeighborInterpolation

        void LinearInterpolation(
            double factor, int bytesPerPixel, BitmapSource source,
            WriteableBitmap destination, int destinationWidth,
            byte[] sourceArray, byte[] destinationArray, int sourceStride,
            int destinationStride, byte fillByte)
        {
            for (int y = 0; y < destination.PixelHeight - 1; ++y) {
                double compression = horizontalScaleFrom + factor * y;
                int sourcePixelWidthMinusOne = source.PixelWidth;
                source.CopyPixels(
                    new Int32Rect(
                        0, y, source.PixelWidth, 1),
                        sourceArray, sourceStride, 0);
                for (int х = 0; х < destinationWidth; ++х) {
                    double shift = (source.PixelWidth *
                        compression - destinationWidth) / 2d;
                    double xSrc = (х + shift) / compression;
                    int xSrcCurrent = (int)xSrc;
                    int xSrcNext = (xSrcCurrent == sourcePixelWidthMinusOne) ?
                        xSrcCurrent : xSrcCurrent + 1;
                    double deltaX = xSrc - xSrcCurrent;
                    for (byte byteIndex = 0; byteIndex < bytesPerPixel; ++byteIndex)
                        if (xSrcCurrent >= 0 && xSrcNext < source.PixelWidth) {
                            if (xSrcCurrent != xSrcNext) {
                                double y1 =
                                    sourceArray[
                                        xSrcCurrent * bytesPerPixel + byteIndex
                                    ];
                                double y2 = sourceArray[
                                    xSrcNext * bytesPerPixel + byteIndex
                                    ];
                                destinationArray[х * bytesPerPixel + byteIndex]
                                    = (byte)(y1 + (y2 - y1) * deltaX);
                            } else
                                destinationArray[х * bytesPerPixel + byteIndex]
                                = sourceArray
                                    [xSrcCurrent * bytesPerPixel + byteIndex];
                        } else
                            destinationArray[х * bytesPerPixel + byteIndex] = fillByte;
                } //loop x
                destination.WritePixels(
                    new Int32Rect(0, y, destinationWidth, 1),
                        destinationArray, destinationStride, 0);
            } //loop y
        } //LinearInterpolation

        internal double HorizontalScaleFrom { get { return horizontalScaleFrom; } }
        internal double HorizontalScaleTo { get { return horizontalScaleTo; } }
        internal enum Interpolation { Linear, NearestNeighbor, }
        internal Interpolation UseInterpolation { get; set; }

        double horizontalScaleFrom, horizontalScaleTo, maxHorizontalScale;

    } //class PerspectiveTransform

} //namespace AnamorphicDrawing.Main

变换的像素重采样是通过两种插值算法实现的:最近邻(图中未显示)和更精确的线性插值。事实上,最近邻并未被使用;它是在第一阶段开发的。

上面显示的变换只接受两个参数:两个长宽比值 `horizontalScaleTo` 和 `horizontalScaleTo`。第一个是为照片的上边缘计算的,第二个是为下边缘计算的。这两个值中的最大值用于确保所有尺寸都仅向下缩放,因为下缩放重采样更准确。

为了计算这两个重采样比例值,我们需要用户传递给软件的参数。这包括仅 9 个数字:软件从图像本身找到的图像长宽比,以及用户输入(如上所示)的四个角点的坐标。

Geometry

图像的长宽比和四个角点用于计算透视变换的几何形状,这由 `MarkFactorSet` 类完成。计算出的几何参数被传递以计算重采样比例值。

UI 层拾取角点数据以计算结果的变换后的位图。

BitmapSource TransformImplementation() {
   return Main.ImageProcessingUtility.PerspectiveTransformFromRelativePoints(
       collaboration.BitmapSource, topLeft.Location, topRight.Location,
       bottomRight.Location, bottomLeft.Location, image.Width);
} //TransformImplementation

`PerspectiveTransformFromRelativePoints` 方法添加了图像的长宽比,计算了几何形状并获得了重采样比例因子。

internal static BitmapSource PerspectiveTransformFromRelativePoints(
    BitmapSource bitmap, Point topLeft, Point topRight, Point bottomRight,
    Point bottomLeft, double imageWidth)
{
    double bitmapAspect = (double)bitmap.PixelWidth / (double)bitmap.PixelHeight;
    MarkFactorSet factorSet = new MarkFactorSet(topLeft, topRight, bottomRight,
        bottomLeft, bitmap.PixelHeight, bitmapAspect, bitmap.PixelWidth / imageWidth);
    if (!factorSet.IsValid) return null;
    return new PerspectiveTransform(
        factorSet.HorizontalScaleFrom, factorSet.HorizontalScaleTo).
            Apply(bitmap);
}

`MarkFactorSet` 类使用非常简单的学校几何知识来计算比例因子。请参阅完整的源代码以获取更多详细信息。

用户界面

用户界面非常简单,但就项目开发方法和可维护性而言,却相当有趣。

首先,它实现为一个单一的主 WPF 窗口,不包括两个帮助窗口。3 步向导的设计基于三个 `UserControl` 实例堆叠在一起。向导导航(“上一步”/“下一步”)是通过控制这些 `UserControl` 实例的可见性来完成的。

此应用程序可用作 XAML 中图形细节行为描述技术的演示,并由依赖属性 `DependencyProperty` 提供支持。

抓手状态的变化会自动向用户显示视觉提示。让我们通过一个更丰富的角抓手示例来回顾这项技术。使用这种方法,我们可以将这些控件的视觉方面与其非视觉方面分离开来。为了强调这种隔离,我还使用 `partial` 类 C# 功能将同一个类的一部分拆分到两个不同的文件中。

看起来像这样:

internal abstract class BaseGrip : ResizeGrip {
    // this class defines keyboard handling,
    // all those move steps, and the like
}

派生类 `BaseGrip` 重写了特定于其行为的键盘处理细节。

internal partial class CropGrip : BaseGrip {

    protected override void OnMouseDown(System.Windows.Input.MouseButtonEventArgs e) {
        Focus();
    } //OnMouseDown

    protected override bool IsGoodMotionKey(KeyEventArgs e) {
        if (this.Direction == GripDirection.Horizontal) {
            if (e.Key == Key.System) {
                return e.SystemKey == Key.Left || e.SystemKey == Key.Right;
            } else {
                return e.Key == Key.Down || e.Key == Key.Left || e.Key == Key.Right;
            } // if System
        } else {
            if (e.Key == Key.System) {
                return e.SystemKey == Key.Up || e.SystemKey == Key.Down;
            } else {
                return e.Key == Key.Up || e.Key == Key.Down;
            } // if System
        } //if Direction
    } //IsGoodMotionKey

} //CropGrip

最后,我们需要使用 XAML 中使用的属性来增强同一个类。

partial class CropGrip {

    enum GripRoleValue : byte {
        HighValueMask = 0x0f,
        OrientationMask = 0xf0,
        HighValue = 1,
        Vertical = 0x10,
        Horizontal = 0x20,
    } //GripRoleValue

    internal enum GripRole : byte {
        Left = GripRoleValue.Horizontal,
        Right = GripRoleValue.Horizontal | GripRoleValue.HighValue,
        Top = GripRoleValue.Vertical,
        Bottom = GripRoleValue.Vertical | GripRoleValue.HighValue
    } //enum GripRole

    internal enum GripDirection :
        byte { Horizontal = GripRoleValue.Horizontal, Vertical = GripRoleValue.Vertical, }

    static FrameworkPropertyMetadata RolePropertyMetadata = new FrameworkPropertyMetadata();

    static DependencyProperty RoleProperty =
        DependencyProperty.Register(
            "Role",
            typeof(GripRole),
            typeof(CropGrip),
            RolePropertyMetadata);

    internal GripRole Role {
        get { return (GripRole)GetValue(RoleProperty); }
        set { SetValue(RoleProperty, value); }
    } //Role

    internal bool IsHighValueRole { get {
        return ((byte)Role & (byte)GripRoleValue.HighValueMask) > 0; }
    }
    internal GripDirection Direction { get {
        return (GripDirection)((byte)Role & (byte)GripRoleValue.OrientationMask); }
    }

} //CropGrip

要在 XAML 中使用它,我们需要 `Setter` 和 `MultiTrigger` 元素。重要的是,我们应该为所有抓手定义通用样式,而不是将它们应用于每个抓手元素。

<ResourceDictionary>
    <!-- ... -->
    <Style x:Key="grip" TargetType="ui:LocationGrip">
        <Setter Property="Cursor" Value="SizeAll"/>
        <Setter Property="Width" Value="{StaticResource locationGripSelectionSize}"/>
        <Setter Property="Height" Value="{StaticResource locationGripSelectionSize}"/>
        <Style.Triggers>
            <MultiTrigger>
                <MultiTrigger.Conditions>
                    <Condition Property="IsMouseOver" Value="true"/>
                    <Condition Property="IsSelected" Value="true"/>
                </MultiTrigger.Conditions>
                <MultiTrigger.Setters>
                    <Setter Property="Background" Value="Red"/>
                </MultiTrigger.Setters>
            </MultiTrigger>
            <MultiTrigger>
                <MultiTrigger.Conditions>
                    <Condition Property="IsMouseOver" Value="true"/>
                    <Condition Property="IsSelected" Value="false"/>
                </MultiTrigger.Conditions>
                <MultiTrigger.Setters>
                    <Setter Property="Background" Value="Green"/>
                </MultiTrigger.Setters>
            </MultiTrigger>
            <Trigger Property="IsMouseOver" Value="false">
                <Setter Property="Background" Value="Transparent"/>
            </Trigger>
            <Trigger Property="IsSelected" Value="true">
                <Setter Property="Foreground" Value="Navy"/>
            </Trigger>
            <Trigger Property="IsSelected" Value="false">
                <Setter Property="Foreground" Value="DarkGray"/>
            </Trigger>
            <Trigger Property="IsSelected" Value="true">
                <Setter Property="Template" Value="{StaticResource ResourceKey=focusedSelected}"/>
            </Trigger>
            <Trigger Property="IsSelected" Value="false">
                <Setter Property="Template" Value="{StaticResource ResourceKey=unSelected}"/>
            </Trigger>
        </Style.Triggers>
    </Style>
</ResourceDictionary>

拥有了样式后,我们可以将其应用于实际的抓手元素。

<UserControl x:Class="AnamorphicDrawing.Ui.CropStep" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:system="clr-namespace:System;assembly=mscorlib" xmlns:ui="clr-namespace:AnamorphicDrawing.Ui">

<!-- ... -->

<ui:CropGrip x:Name="left" Role="Left" Cursor="SizeWE" Style="{StaticResource vertical}"/>
<ui:CropGrip x:Name="top" Role="Top" Cursor="SizeNS" Style="{StaticResource horizontal}"/>
<ui:CropGrip x:Name="right" Role="Right" Cursor="SizeWE" Style="{StaticResource vertical}"/>
<ui:CropGrip x:Name="bottom" Role="Bottom" Cursor="SizeNS" Style="{StaticResource horizontal}"/>

<!-- ... -->

</UserControl>

用户界面设计:协作和抓手连接器

此项目还包含许多其他有趣的设计构造。特别是,`ICollaborationProvider` 接口和 `Collaboration` 类提供了代表三个向导步骤的 `UserControl` 实例之间的协作。这种协作很重要,因为它们共享同一个 `BitmapSource` 对象。为此,所有三个 `UserControl` 步骤类都实现了 `ICollaborationProvider` 接口。通过这种方法,所有三个类都保持隔离,仅提供协作所需的必需属性的访问。

`CropGripCoupler` 和 `LocationGripCoupler` 类扮演着类似的角色。`CropGripCoupler` 类将所有四个角抓手的位置与连接它们的线条的位置耦合起来。这些线条对用户很重要,他们需要视觉提示来了解抓手的顺序。精确匹配这些线条与梯形的边缘也很有帮助。同样,`LocationGripCoupler` 类将裁剪抓手(crop grips)的行为与裁剪矩形(crop rectangle)耦合起来。这实际上是松耦合的一个例子,具有其所有优点。确实,开发包含每种类型的全部四个抓手的控件会困难得多,从而失去了将每个抓手类开发成相当简单、独立控件类的机会。

在一篇文章中解释所有有趣细节是相当困难的。请参考源代码,目录“AnamorphicDrawing/UI”和“AnamorphicDrawing/UI.Control”。我很乐意回答有关此问题的所有问题。

构建和兼容性

由于代码基于 WPF,我使用了第一个与 WPF 兼容性良好的平台版本 — Microsoft .NET v.3.5。相应地,我为 Visual Studio 2008 提供了一个解决方案和项目。我这样做是故意的,目的是涵盖所有可能使用 WPF 的读者。以后将支持 .NET 版本;稍后的 Visual Studio 版本可以自动升级解决方案和项目文件。

实际上,构建并不需要 Visual Studio。代码可以通过提供的批处理文件“build.bat”进行批处理构建。如果您的 Windows 安装目录与默认目录不同,构建仍然有效。如果 .NET 安装目录与默认目录不同,请参阅此批处理文件的内容及其第一行的注释 — 下一行可以修改以完成构建。

© . All rights reserved.