将高速压缩注入您现有的 Java 应用程序
利用 PICTools 高性能原生库的强大功能,让您的 Java 应用程序在 JPEG 压缩速度上比 Java 原生支持快 7 倍。本白皮书和示例代码将介绍如何将此性能集成到现有 Java 应用程序中,而无需完全重写
利用 PICTools 高性能原生库的强大功能,让您的 Java 应用程序在 JPEG 压缩速度上比 Java 原生支持快 7 倍。本白皮书和示例代码将介绍如何将此性能集成到现有 Java 应用程序中,而无需完全重写。全球一些最大的医疗、照片和文档成像公司在过去 10 多年里一直依赖 PICTools 库的性能和可靠性。当性能至关重要时,可以通过 JNI 接口访问 PICTools 原生库,为现有 Java 应用程序提供所需的性能。
开始之前
PICTools 工具包在 Windows、Linux、Solaris、AIX 和 Mac OSX 上提供 32 位和 64 位版本。Android 的 Java 环境和 Apple iOS 由 PICTools 的移动版本 AIMTools 支持。
PICTools 工具包有几种版本,提供广泛的功能以满足您正在构建的应用程序的需求。请访问 http://www.accusoft.com/pictools.htm 了解更多信息。本教程演示了 PICTools Photo Edition 中的功能。
要编译本教程,您需要下载 32 位 PICTools Photo 工具包,并确保您的开发环境中安装了 32 位版本的 Java Development Kit (JDK) 1.6(Java 6)或更高版本。您可以从 Oracle 网站下载 JDK:http://www.oracle.com/technetwork/java/javase/downloads/index.html。您还需要安装 Microsoft Visual Studio 2010。
如果您暂时不想编译程序,我们提供了一个 已编译的演示版本,您可以在 demo 目录中运行。您还可以浏览 Source 目录中提供的源代码。
编写 Java 应用程序
我们将编写的 Java 应用程序将从磁盘读取一个位图文件,使用 JPEG 压缩对其进行压缩,然后使用 JFIF 文件格式将其写出。为了进行比较,我们将使用 javax.imageio.ImageIO 类执行此操作,然后使用 PICTools 执行相同的操作。PICTools 实现将提供质量和压缩设置,以生成尽可能接近 Java 库生成的图像的 JPEG 压缩图像。
在“Source”目录中,您会找到一个名为“CompileAndRun.bat”的批处理文件。我们调用的是 32 位原生库,因此必须确保调用 32 位版本的 Java 编译器和运行时。该批处理文件已提供,方便您编译和运行程序。下面显示的 Java 文件的源代码位于“Source/Java/Accusoft”目录中的“MyClass.java”文件中。
注意:这是使用 JDK1.6 Update 25 编译的。您可能需要根据您安装的 Java 版本更改批处理文件中的路径。
计时是为了比较这两种操作,包括从磁盘读取源文件和写出新文件的时间。使用 ImageIO.write()
方法无法在不包含磁盘 I/O 的情况下测量压缩时间,这会影响计时结果。因此,从磁盘读取文件、压缩图像和将文件写出到磁盘的总时间包含在计时测量中。PICTools 计时还包括一项额外的计时测量,仅为压缩时间(以毫秒为单位)。
package Accusoft;
public class MyClass
{
public native int PicToolsMakeJPG(String srcBmp, String tgtJpg);
static long pictime = -1;
public static void main(String[] args) throws java.io.IOException
{
long start, stop;
if (args.length < 1)
{
String msg = "Copyright 2012 Accusoft Corporation. All rights reserved.\n" +
"Java PICTools Tutorial.\n" +
"Usage: source.bmp";
System.out.println(msg);
System.exit(1);
}
// Load the PicToolsWrapper.dll library. We are excluding this from timing
// because this incurs a one-time cost the first time it is loaded.
System.loadLibrary("PicToolsWrapper");
java.io.File file = new java.io.File(args[0]);
// Compress a bitmap using Java libraries.
// For demonstration the output file names are hard coded.
start = System.currentTimeMillis(); // start timing
java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(file);
java.io.File output = new java.io.File(file.getName() + "-javax_imageio.jpg");
javax.imageio.ImageIO.write(image, "jpg", output);
stop = System.currentTimeMillis(); // end timing
System.out.println(String.format("Total Time Java: %s ms",stop-start));
Accusoft.MyClass test = new Accusoft.MyClass();
String outfile = file.getName() + "-pictoolsJNI.jpg";
// The PICTools Photo toolkit is in evaluation mode which displays a
// dialog for 5 seconds the first time we call the opcode. We time the next
// call to get a true representation of the time.
int status = test.PicToolsMakeJPG(args[0], outfile);
if (status == 0)
{
// Compress a bitmap using PICTools called through Java JNI.
start = System.currentTimeMillis(); // start timing
status = test.PicToolsMakeJPG(args[0], outfile);
stop = System.currentTimeMillis(); // end timing
if (status == 0)
{
String msg = "Total Time PICTools: %s ms, compress time %s ms";
System.out.println(String.format(msg,stop-start,pictime));
}
else
ReportError(status);
}
else
{
ReportError(status);
}
System.exit(0);
}
private static void ReportError(int statuscode)
{
// An error occured.
String msg = String.format("\nThe error code %d was returned. ", statuscode);
msg += "Failed to create JPEG compressed file.\n";
if (statuscode == 4)
msg += "A 24bpp bitmap image is required as the source image.";
else if (statuscode == -2101)
msg += String.format ("The file picn1020.dll was not found in your path.");
else if (statuscode == 1)
msg += "Failed to open the file input file.";
System.out.println(msg);
}
}
编写 Windows DLL
创建可从 Java 调用的大型库需要一些工作,但这是一次性成本,之后可以被您所有 Java 应用程序利用。本教程将解释创建 Windows DLL 并正确公开可从您的 Java 应用程序调用的原生函数的必要步骤。之后,您可以以此为基础,添加 PICTools 工具包提供的更高级功能,并根据需要将其扩展到其他平台。
创建 Visual Studio 项目
- 使用 Visual Studio 新建项目向导,创建一个新的 Visual C++ Win32 项目,并将其命名为“PicToolsWrapper”。
- 当向导启动时,导航到“应用程序设置”页面,并选择“DLL”作为应用程序类型。
- 单击“完成”退出向导。
添加 Java Native Interface 头文件
创建项目后,我们需要将 jni.h 头文件添加到 dllmain.cpp 文件中。除非您之前已经为使用 Java 的其他项目设置了 Visual Studio,否则 Visual Studio 很可能找不到 JNI 头文件。打开项目的设置,并添加指向您计算机上安装的 Java JDK 的包含路径。
在 dllmain.cpp 文件中,在包含 stdafx.h 文件的行下方添加以下行,然后编译项目。它应该可以无错误地编译。
// dllmain.cpp : Defines the entry point for the DLL application.
#include "stdafx.h"
// Add the Java JDK include path to Visual Studio
// This is the default 32bit Java 1.6 SDK path on 64bit Windows 7.
// C:\Program Files (x86)\Java\jdk1.6.0_25\include
// C:\Program Files (x86)\Java\jdk1.6.0_25\include\win32
#include <jni.h>
添加 JNI 原生方法
接下来,我们将定义将在 Java 应用程序中调用的原生方法。当 Java 虚拟机(JVM)调用我们的函数时,它会传递一个 JNIEnv 指针、一个 jobject 指针以及 Java 方法声明的任何参数。JNIEXPORT 和 JNICALL 定义在 jni.h 头文件中,它们是 __declspec(dllexport)(从 Windows DLL 导出函数)和 __stdcall(Windows API 调用约定)的别名。编译器会更改方法名以包含参数和返回类型。通过使用 extern "C",我们指示编译器不要更改函数名。
extern "C" JNIEXPORT void JNICALL Java_ClassName_MethodName
(JNIEnv *env, jobject obj)
{
// Native Method
}
从 Java JNI 形式签名,我们可以编写原生方法。请注意,我们必须在原生方法名中使用完全限定的 Java 类名。我们还引入了 Java 类型 jint 和 jstring,它们定义在 jni.h 文件中,并提供了 Java 类型和原生类型之间的映射。
extern "C" JNIEXPORT jint JNICALL Java_Accusoft_MyClass_PicToolsMakeJPG
(JNIEnv *env, jobject obj, jstring srcfile, jstring tgtfile)
{
// Native Method
}
将 Java 类型转换为原生类型
在此示例中,我们将两个字符串作为参数传递给我们的函数。Java 语言中的 String 对象,在 Java Native Interface (JNI) 中表示为 jstring,是一个 16 位 Unicode 字符串。转换函数 GetStringUTFChars()
将分配内存并检索字符串的 8 位表示。因为我们分配了内存,所以必须调用 ReleaseStringUTFChars()
函数来告知 Java 虚拟机它可以释放它分配的内存。函数 internal_PicToolsMakeJPG
将我们 Java 原生方法的“管道”与 PICTools 将执行的工作分离开来。将此函数复制到您的 dllmain.cpp 文件中,放在 DllMain
函数下方。
extern "C" JNIEXPORT jint JNICALL Java_Accusoft_MyClass_PicToolsMakeJPG
(JNIEnv *env, jobject obj, jstring srcfile, jstring tgtfile)
{
// This is the main interface to Java. It separates the plumbing required
// by Java from the implementation of the PICTools code.
// get an 8-bit representation of the java string.
const char *pszSrcBmp = env->GetStringUTFChars(srcfile, 0);
if (pszSrcBmp == NULL)
return NULL; // out of memory
const char *pszTgtJPG = env->GetStringUTFChars(tgtfile, 0);
if (pszTgtJPG == NULL)
{
env->ReleaseStringUTFChars(srcfile, pszSrcBmp); // clean up first string
return NULL; // out of memory
}
// call the pictools implementation.
long compressTime = -1;
int output = internal_PicToolsMakeJPG(pszSrcBmp, pszTgtJPG, &compressTime);
// This captures the time in milliseconds for the actual PICTools compression
// and does not include the I/O time to read the file and write it to disk.
// Here we are seting the value of a static variable in the Java class.
jclass cls = env->GetObjectClass(obj);
jfieldID fid = env->GetStaticFieldID(cls, "pictime", "J");
if (fid != NULL)
{
jlong time = compressTime;
env->SetStaticLongField(cls, fid, time);
}
// You must do this to prevent a memory leak in Java.
env->ReleaseStringUTFChars(srcfile, pszSrcBmp);
env->ReleaseStringUTFChars(tgtfile, pszTgtJPG);
return output;
}
PICTools API 简介
如果您不熟悉 PICTools 架构,现在是时候对执行 JPEG 压缩的代码进行简要概述。请参阅“PICTools 和 AIMTools 程序员指南”和“PICTools 快速入门指南”以全面了解架构。
PICTools 采用插件式架构,其中 DLL(称为操作码)在运行时动态加载,以执行压缩、解压缩和图像处理。您的应用程序链接到 PICTools 分派器,这是一个负责在运行时加载操作码的 DLL,也是您将数据在应用程序和操作码之间传输的主要方式。
分派器导出了两个主要函数:**PegasusQuery** 和 **Pegasus**。第一个函数用于确定图像类型。在我们的例子中,在我们分配内存并将位图加载到缓冲区后,此函数将读取缓冲区中的足够数据以确定图像类型。第二个函数是您的应用程序与操作码通信的主要方式。它通过传递指向 PIC_PARM
结构的指针(定义在 pic.h 文件中)并传递一个常量来指示请求的操作来完成此操作,该常量将用于执行初始化、执行和终止。
这是从 pic.h 文件中摘录的 PIC_PARM
结构。 “Op”字段用于告诉分派器加载哪个操作码(DLL)。“Head”字段将在调用 PegasusQuery 后填充,并返回有关我们要 JPEG 压缩的位图的信息。“Get”字段是指向我们应用程序已分配的内存的指针,其中包含位图图像。“Put”字段是指向我们应用程序已分配的内存的指针,其中将包含 JPEG 压缩图像。
typedef struct PIC_PARM_TAG {
...
long Op;
BITMAPINFOHEADER Head;
RGBQUAD ColorTable[272]
QUEUE Get;
QUEUE Put;
...
} PIC_PARM;
下表显示了本教程中使用的 DLL。
操作码 | DLL | 描述 |
不适用 | picn20.dll | 32 位 PicTools 分派器,应用程序通过 picnm.lib 链接使用此 DLL。导出 PegasusQuery() 和 Pegasus() 函数。 |
OP_D2S | picn1020.dll | DIB 转 Huffman 顺序 JPEG |
使用 PICTools API
在本例中,我们假设您已将 PICTools Photo 工具包解压缩到 C:\PICTools 目录。打开您的 Visual Studio 项目设置,为头文件添加指向 C:\PICTools\include 的路径,并为库路径添加指向 C:\PICTools\lib 的路径。在 Linker 设置中,您还需要添加 picnm.lib 库。
在 dllmain.cpp 文件中,在 Dllmain 函数上方,添加 PICTools 头文件和下面的函数原型,然后在 dllmain.cpp 文件的末尾添加函数体。项目应能成功编译。WINAPI 宏是我们之前讨论过的 __stdcall 约定的别名。请注意,在本示例中,为了说明概念,错误处理已降至最低。使用任意返回值来演示潜在错误。确保您的应用程序提供您可能需要的任何必要的错误处理。
#include <stdlib.h>
#include <time.h>
#include "pic.h"
#include "errors.h"
// Function prototype
int WINAPI internal_PicToolsMakeJPG(const char *srcbmp, const char *tgtjpg, long *compresstime);
// Function body
int WINAPI internal_PicToolsMakeJPG(const char *srcbmp, const char *tgtjpg, long *compresstime
)
{
return 0;
}
填充函数体
调用操作码执行操作时必须执行的三个主要步骤是初始化、执行和终止。在调用任何 PICTools API 函数之前,必须将 PIC_PARM
结构初始化为零,以确保我们具有合理的默认值。之后,您可以通过修改 PIC_PARM
结构来执行特定于操作码的初始化,然后再调用 pegasus 函数。请参阅“PICTools 程序员参考”以全面了解在使用特定操作码之前必须初始化的内容。
第一次调用操作码时会显示一个五秒钟的评估对话框,除非您提供了注册码。当您购买工具包并获得注册码后,只需将其添加到您的源代码中并重新编译项目。
// Initialization
PIC_PARM p;
RESPONSE res;
char *pszRegistrationName = NULL;
char *pszDispatcherRegistrationName = NULL;
unsigned long RegistrationCode = 0x00000000;
unsigned long DispatcherRegistrationCode = 0x00000000;
// Initialize picparm structure
memset (&p,0,sizeof(PIC_PARM));
p.ParmSize = sizeof(PIC_PARM);
p.ParmVer = CURRENT_PARMVER;
p.ParmVerMinor = 1;
// This tells the dispatcher(picn20.dll) to use opcode OP_D2S (picn1020.dll)
// to perform sequential JPEG compression.
p.Op = OP_D2S;
// A dialog box is displayed for 5 seconds the first time you call the
// PICTools opcode if you do not supply registration codes.
p.ReservedPtr6 = (BYTE*)pszRegistrationName;
p.ReservedPtr7 = (BYTE*)(PICINTPTRT)RegistrationCode;
if ( DispatcherRegistrationCode != 0 )
{
p.Flags |= F_ReservedPtr5;
p.ReservedPtr4 = (BYTE*)pszDispatcherRegistrationName;
p.ReservedPtr5 = (BYTE*)(PICINTPTRT)DispatcherRegistrationCode;
}
Get
队列是一个结构,包含 Start 和 End 指针,用于确定缓冲区大小。它还包含 Front 和 Rear 指针,用于确定您要如何从队列中读取数据。如果您将队列想象成一条水平线,那么问题就变成了我们是从左到右读取还是从右到左读取。操作码将从 Get
队列读取图像数据。在此步骤中,我们分配内存并将整个文件读入缓冲区。更高级的技术允许您分块处理图像,并让操作码将进度报告回应用程序。
// Read source file into a buffer.
FILE *fp;
if (fopen_s(&fp, srcbmp, "rb") != 0)
return 1; // failed to open the file.
fseek(fp,0L, SEEK_END);
long size = ftell(fp);
fseek(fp, 0, SEEK_SET);
// Allocate memory and setup the Get queue pointers.
p.Get.Start = (unsigned char *)malloc(size);
if (p.Get.Start == NULL)
{
fclose(fp);
return -1; // out of memory
}
p.Get.End = p.Get.Start + size;
p.Get.Front = p.Get.Start;
p.Get.Rear = p.Get.End;
// this flag tells the opcode the entire image is in the buffer
p.Get.QFlags = Q_EOF;
int bytesRead = fread(p.Get.Start,1,size,fp);
if (bytesRead != size)
{
fclose(fp);
free(p.Get.Start); // free allocated memory.
return 2; // failed to read the whole file.
}
fclose(fp);
将文件读入我们的 Get
队列后,我们可以调用 PegasusQuery()
函数来确定文件类型。这将用有关位图的信息填充 BITMAPHEADER
结构。
// Ensure the file is a 24bpp bitmap. The flags tell PegasusQuery that
// we require specific information about the image in the Get Queue. See
// the "PICTools Programmer's Reference" for a complete discussion of these
// flags.
p.u.QRY.BitFlagsReq = QBIT_BICOMPRESSION | QBIT_BICLRIMPORTANT | QBIT_BIBITCOUNT;
if (!PegasusQuery(&p))
{
// Failed to retrieve the required information about the image.
free (p.Get.Start);
return 3; // Failed to determine image type.
}
memset(&p.u.QRY, 0, sizeof(p.u.QRY));
// This example only demonstrates 24bpp RGB bitmap files.
if (p.Head.biCompression != BI_RGB || p.Head.biClrImportant != 0
|| p.Head.biBitCount != 24)
{
free (p.Get.Start);
return 4; // Not a 24bpp bitmap image.
}
// The pixel data for Device-Independent Bitmaps (DIB) are stored in reverse
// order where the top line on the screen is the last line in the DIB buffer.
// The Start and End pointers tell us the size of our buffer and the Front and
// Rear pointers are used by the queue. We need to orient the queue to read
// from the end of the buffer toward the start. After adjusting the Front
// and Rear pointers set the flag, Q_REVERSE, to tell the opcode which end
// of the queue to start reading from. We also have to adjust the Rear
// pointer so that it does not include the BITMAPFILEHEADER and
// BITMAPINFOHEADER data.
// The opcode will read the image data from the Get queue.
p.Get.Rear = p.Get.Front + sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER);
p.Get.Front = p.Get.End;
p.Get.QFlags |= Q_REVERSE;
// The LumFactor and ChromFactor are in the range of 0-255. When they are both
// zero, the quality is highest and the compression ratio lowest. When they are
// both at 255, the quality is lowest and the compression ratio is highest. The
// value of 32 says to use the default luminance and chrominance quantization
// table values.
// The SubSampling is set so Cb and Cr are sub-sampled 2 to 1 vertically and
// horizontally. The PF_OptimizeHuff flag is used to tell the opcode to create
// optimized Huffman codes during compression instead of using the default
// Huffman codes.
p.u.D2S.LumFactor = 32;
p.u.D2S.ChromFactor = 32;
p.u.D2S.SubSampling = SS_411;
p.u.D2S.PicFlags |= PF_OptimizeHuff;
在开始 PICTools 操作之前,我们为 Put
队列分配内存,操作码将在此处写入压缩的 JPEG 图像。
// The opcode will write the image data to the Put queue, so we must allocate
// memory for it. In this case, we know that the output file (JPG) will be
// smaller than our input size, so we will just allocate the same size output
// buffer as we did for the input.
p.Put.Start = (unsigned char*)malloc(size);
if (p.Put.Start == NULL)
{
free(p.Get.Start);
return -1; // out of memory
}
// JPEG image data is stored top-down, so we do not have to reverse the
// queue Front and Rear pointers.
p.Put.End = p.Put.Start + size;
p.Put.Front = p.Put.Start;
p.Put.Rear = p.Put.Start;
long start_time = clock();
// Loads and initializes the opcode.
res = Pegasus(&p, REQ_INIT);
while (res != RES_DONE)
{
if (res == RES_ERR)
{
// This may be a common error during initial development. If you
// get this, ensure that the opcode is in your search path.
if (p.Status == ERR_OPCODE_DLL_NOT_FOUND) // (-2101)
{
// opcode dll not found.
free(p.Get.Start);
free(p.Put.Start);
// Dispatcher returned the error (-2101) which is defined in
// the PICTools header file errors.h.
return p.Status;
}
}
res = Pegasus(&p, REQ_CONT);
}
// Execution and Termination
// Ensure we did not have an initialization error.
if (res != RES_ERR)
{
// Execute the opcode. The result when this function returns is a
// JPEG compressed image in the Put queue.
res = Pegasus(&p, REQ_EXEC);
// Call this to allow the opcode to free internally allocated memory.
Pegasus(&p, REQ_TERM);
if (res != RES_DONE)
{
free(p.Get.Start);
free(p.Put.Start);
// The opcode has returned an error. These are defined in the
// PICTools header file error.h.
return p.Status;
}
}
long end_time = clock();
*compresstime = ((end_time - start_time)*1000)/CLOCKS_PER_SEC;
最后,我们将文件写出到磁盘并释放我们分配的内存。
// Write the image data from the Put queue out to a file.
if (fopen_s(&fp, tgtjpg, "w+b") != 0)
return 6; // failed to open file for write.
// Subtract the Front pointer from the Rear pointer to find out how
// much data was written and then write it to a file.
size_t len = p.Put.Rear-p.Put.Front;
if (fwrite(p.Put.Front,1,len,fp) < len)
{
free(p.Get.Start);
free(p.Put.Start);
fclose(fp);
return 7; // Failed to write whole file.
}
fclose(fp);
free(p.Get.Start);
free(p.Put.Start);
p.Get.Start = NULL;
p.Put.Start = NULL;
return 0;
运行程序
演示程序
“Demo”目录中提供了一个已编译的演示程序。因为我们加载的是 32 位原生库,所以必须调用 32 位 JVM。提供了一个名为“RunDemo.bat”的批处理文件,它确保我们运行的是正确版本。该批处理文件还将 PATH 环境变量设置为当前目录,以确保 PicToolsWrapper.dll、picn20.dll 和 picn1020.dll 文件在运行时可以被找到和加载。批处理文件退出时,PATH 变量会恢复到原始值。
运行批处理文件时,它将读取源 .bmp 图像并生成 java-pictoolsJNI.jpg 和 javax-imageio.jpg 文件。它还将显示从磁盘读取文件、执行压缩并将新文件写入磁盘所需的时间。PICTools 示例代码设置了质量和压缩比率设置,以生成尽可能接近 Java 实现所用设置的图像。
已编译程序
在“Source\Java”目录中,您会找到一个名为“CompileAndRun.bat”的批处理文件。它将编译 Java 程序然后运行该程序。如果您已按照教程进行操作并创建了自己的 PicToolsWrapper 项目,则需要将您编译的 DLL PICToolsWrapper.dll 从输出目录复制到“Source\Java\Accusoft”目录。
该批处理文件还将“当前目录\Accusoft”添加到 PATH 环境变量,以确保 PicToolsWrapper.dll、picn20.dll 和 picn1020.dll 文件在运行时可以被找到和加载。批处理文件退出时,PATH 变量会恢复到原始值。
DLL | 描述 |
PicToolsWrapper.dll | 本教程中创建的 Windows DLL。 |
picn20.dll | 32 位 PicTools 分派器,应用程序通过 picnm.lib 链接使用此 DLL。导出 PegasusQuery () 和 Pegasus() 函数。 |
picn1020.dll | DIB 转 Huffman 顺序 JPEG |
结论
当性能至关重要时,PICTools 可以通过 Java Native Interface (JNI) 插入到您现有的应用程序中,从而使 JPEG 图像压缩速度提高 7 倍。还有其他 PICTools 库可用于提供高速成像支持,例如图像清理、图像增强以及多种图像格式的压缩和解压缩。PICTools 架构允许您仅包含所需成像支持所需的库,使您的二进制文件尽可能小,并在当今最流行的平台上进行开发。有关更多信息并下载功能齐全的试用版,请访问:www.accusoft.com/pictools.htm。
关于作者
Andy Dunlap 是 Accusoft 的高级软件工程师。在 2011 年加入 Accusoft 之前,Andy 在海军陆战队担任 AV-8B Harrier 航电设备技师和教官 10 年,之后从硬件转向软件。他的软件职业生涯始于一家位于佛罗里达州坦帕的飞行模拟器公司,在那里他利用自己的飞机知识在 Unix 和 Windows 平台上用 Fortran、C 和 C++ 设计实时飞行模拟器软件。出于改变职业的愿望,他随后开始从事商业软件工作,在那里他开发了使用 Microsoft SQL Server、Win32、.NET 和 COM+ 技术的数据库应用程序。
目前,他正在
作为 Accusoft 原生核心成像团队的一员,从事成像技术相关工作。Andy 在 Park University 获得了计算机科学学士学位。