Sunday, January 29, 2006

Generating non-pixelated thumbnail images in .NET

The .NET Framework comes with a large set of image manipulation libraries.  Generating thumbnail images becomes very easy.  But image pixelation, if it were a feature, is turned on by default. :)  Here is the basic code to generate a thumbnail without pixelation.  It's inspired by TheLomex on an ASP.NET forum thread.  I have adapted it to C#.
Bitmap original; // your original image
Size resolution = new Size(150, 150); // size of your thumbnail
Image thumbnail = new Bitmap(original, resolution);
Graphics g = Graphics.FromImage(thumbnail);
// The InterpolationMode was the catalyst to eliminate pixelation.
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
// I'm not sure whether CompositingQuality or SmoothingMode
// contribute at all to good image resizing. Anyone know?
g.CompositingQuality = CompositingQuality.HighQuality;
g.SmoothingMode = SmoothingMode.HighQuality;
g.DrawImage(original, new Rectangle(0, 0, thumbnail.Size.Width, thumbnail.Size.Height));
// Thumbnail now contains the resized image, all smoothed out and
// non-pixelated!
To save the image now as a JPEG while controlling the compression vs. quality of the saved file, add this code:
// Prepare for a controlled-quality JPEG export
ImageCodecInfo jpegCodec = GetEncoderInfo("image/jpeg");
Encoder jpegEncoder = Encoder.Quality;
EncoderParameters jpegEncoderParameters = new EncoderParameters(1);
EncoderParameter jpegEncoderQuality = new EncoderParameter(jpegEncoder, jpegQuality);
jpegEncoderParameters.Param[0] = jpegEncoderQuality;

string thumbnailPath; // some path to save your JPEG to
thumbnail.Save(thumbnailPath, jpegCodec, jpegEncoderParameters);
And never forget to call Image.Dispose on all your images when you're done using them, using either using or a finally block.

Friday, January 27, 2006

C# Dijkstra's algorithm implementation

I implemented Dijkstra's algorithm using C# for a Computer Science course. I implemented it in a generalized way, that still allows for optimization by the consuming code. I release the code under the MIT license

Here is the code: (it is not as long as it is well-documented)

using System; 
using System.Diagnostics;
using System.Collections.Generic;
using System.Text;

namespace VisualIntelligentScissors
{
/// <summary>
/// Implements a generalized Dijkstra's algorithm to calculate
/// both minimum distance and minimum path.
/// </summary>
/// <remarks>
/// For this algorithm, all nodes should be provided, and handled
/// in the delegate methods, including the start and finish nodes.
/// </remarks>
public class Dijkstra
{
/// <summary>
/// An optional delegate that can help optimize the algorithm
/// by showing it a subset of nodes to consider. Very useful
/// for limited connectivity graphs. (like pixels on a screen!)
/// </summary>
/// <param name="startingNode">
/// The node that is being traveled away FROM.
/// </param>
/// <returns>
/// An array of nodes that might be reached from the
/// <paramref name="startingNode"/>.
/// </returns>
public delegate IEnumerable<int> NearbyNodesHint(int startingNode);
/// <summary>
/// Determines the cost of moving from a given node to another given node.
/// </summary>
/// <param name="start">
/// The node being moved away from.
/// </param>
/// <param name="finish">
/// The node that may be moved to.
/// </param>
/// <returns>
/// The cost of the transition from <paramref name="start"/> to
/// <paramref name="finish"/>, or <see cref="Int32.MaxValue"/>
/// if the transition is impossible (i.e. there is no edge between
/// the two nodes).
/// </returns>
public delegate int InternodeTraversalCost(int start, int finish);

/// <summary>
/// Creates an instance of the <see cref="Dijkstra"/> class.
/// </summary>
/// <param name="totalNodeCount">
/// The total number of nodes in the graph.
/// </param>
/// <param name="traversalCost">
/// The delegate that can provide the cost of a transition between
/// any two nodes.
/// </param>
/// <param name="hint">
/// An optional delegate that can provide a small subset of nodes
/// that a given node may be connected to.
/// </param>
public Dijkstra(int totalNodeCount, InternodeTraversalCost traversalCost, NearbyNodesHint hint)
{
if (totalNodeCount < 3) throw new ArgumentOutOfRangeException("totalNodeCount", totalNodeCount, "Expected a minimum of 3.");
if (traversalCost == null) throw new ArgumentNullException("traversalCost");
Hint = hint;
TraversalCost = traversalCost;
TotalNodeCount = totalNodeCount;
}

protected readonly NearbyNodesHint Hint;
protected readonly InternodeTraversalCost TraversalCost;
protected readonly int TotalNodeCount;

/// <summary>
/// The composite product of a Dijkstra algorithm.
/// </summary>
public struct Results
{
/// <summary>
/// Prepares a Dijkstra results package.
/// </summary>
/// <param name="minimumPath">
/// The minimum path array, where each array element index corresponds
/// to a node designation, and the array element value is a pointer to
/// the node that should be used to travel to this one.
/// </param>
/// <param name="minimumDistance">
/// The minimum distance from the starting node to the given node.
/// </param>
public Results(int[] minimumPath, int[] minimumDistance)
{
MinimumDistance = minimumDistance;
MinimumPath = minimumPath;
}

/// <summary>
/// The minimum path array, where each array element index corresponds
/// to a node designation, and the array element value is a pointer to
/// the node that should be used to travel to this one.
/// </summary>
public readonly int[] MinimumPath;
/// <summary>
/// The minimum distance from the starting node to the given node.
/// </summary>
public readonly int[] MinimumDistance;
}

/// <summary>
/// Performs the Dijkstra algorithm on the data provided when the
/// <see cref="Dijkstra"/> object was instantiated.
/// </summary>
/// <param name="start">
/// The node to use as a starting location.
/// </param>
/// <returns>
/// A struct containing both the minimum distance and minimum path
/// to every node from the given <paramref name="start"/> node.
/// </returns>
public virtual Results Perform(int start)
{
// Initialize the distance to every node from the starting node.
int[] d = GetStartingTraversalCost(start);
// Initialize best path to every node as from the starting node.
int[] p = GetStartingBestPath(start);
ICollection<int> c = GetChoices();

c.Remove(start); // take starting node out of the list of choices

//Debug.WriteLine("Step v C D P");
//Debug.WriteLine(string.Format("init - {{{0}}} [{1}] [{2}]",
// ArrayToString<int>(",", c), ArrayToString<int>(",", d), ArrayToString<int>(",", p)));
//int step = 0;

// begin greedy loop
while (c.Count > 1)
{
// Find element v in c, that minimizes d[v]
int v = FindMinimizingDinC(d, c);
c.Remove(v); // remove v from the list of future solutions
// Consider all unselected nodes and consider their cost from v.
foreach (int w in (Hint != null ? Hint(v) : c))
{
if (!c.Contains(w)) continue; // discard pixels not in c
// At this point, relative(Index) points to a candidate pixel,
// that has not yet been selected, and lies within our area of interest.
// Consider whether it is now within closer reach.
int cost = TraversalCost(v, w);
if (cost < int.MaxValue && d[v] + cost < d[w]) // don't let wrap-around negatives slip by
{
// We have found a better way to get at relative
d[w] = d[v] + cost; // record new distance
// Record how we came to this new pixel
p[w] = v;
}
}
//Debug.WriteLine(string.Format("{4} {3} {{{0}}} [{1}] [{2}]",
// ArrayToString<int>(",", c), ArrayToString<int>(",", d), ArrayToString<int>(",", p), v + 1, ++step));
}

return new Results(p, d);
}

/// <summary>
/// Uses the Dijkstra algorithhm to find the minimum path
/// from one node to another.
/// </summary>
/// <param name="start">
/// The node to use as a starting location.
/// </param>
/// <param name="finish">
/// The node to use as a finishing location.
/// </param>
/// <returns>
/// A struct containing both the minimum distance and minimum path
/// to every node from the given <paramref name="start"/> node.
/// </returns>
public virtual int[] GetMinimumPath(int start, int finish)
{
Results results = Perform(start);
return GetMinimumPath(start, finish, results.MinimumPath);
}

/// <summary>
/// Finds an array of nodes that provide the shortest path
/// from one given node to another.
/// </summary>
/// <param name="start">
/// The starting node.
/// </param>
/// <param name="finish">
/// The finishing node.
/// </param>
/// <param name="shortestPath">
/// The P array of the completed algorithm.
/// </param>
/// <returns>
/// The list of nodes that provide the one step at a time path
/// from <paramref name="start"/> to <paramref name="finish"/> nodes.
/// </returns>
protected virtual int[] GetMinimumPath(int start, int finish, int[] shortestPath)
{
Stack<int> path = new Stack<int>();
do
{
path.Push(finish);
finish = shortestPath[finish]; // step back one step toward the start point
}
while (finish != start);
return path.ToArray();
}

/// <summary>
/// Initializes the P array for the algorithm.
/// </summary>
/// <param name="startingNode">
/// The node that has been designated the starting node for the entire algorithm.
/// </param>
/// <returns>
/// The new P array.
/// </returns>
/// <remarks>
/// A fresh P array will set every single node's source node to be
/// the starting node, including the starting node itself.
/// </remarks>
protected virtual int[] GetStartingBestPath(int startingNode)
{
int[] p = new int[TotalNodeCount];
for (int i = 0; i < p.Length; i++)
p[i] = startingNode;
return p;
}

/// <summary>
/// Finds the yet-unconsidered node that has the least cost to reach.
/// </summary>
/// <param name="d">
/// The cost of reaching any node.
/// </param>
/// <param name="c">
/// The nodes that are still available for picking.
/// </param>
/// <returns>
/// The node that is closest (has the shortest special path).
/// </returns>
protected virtual int FindMinimizingDinC(int[] d, ICollection<int> c)
{
int bestIndex = -1;
foreach (int ci in c)
if (bestIndex == -1 || d[ci] < d[bestIndex])
bestIndex = ci;
return bestIndex;
}

/// <summary>
/// Initializes an collection of all nodes not yet considered.
/// </summary>
/// <returns>
/// The initialized collection.
/// </returns>
protected virtual ICollection<int> GetChoices()
{
ICollection<int> choices = new List<int>(TotalNodeCount);
for (int i = 0; i < TotalNodeCount; i++)
choices.Add(i);
return choices;
}

/// <summary>
/// Initializes the D array for the start of the algorithm.
/// </summary>
/// <param name="start">
/// The starting node.
/// </param>
/// <returns>
/// The contents of the new D array.
/// </returns>
/// <remarks>
/// The traversal cost for every node will be set to impossible
/// (int.MaxValue) unless a connecting edge is found between the
/// <paramref name="start"/>ing node and the node in question.
/// </remarks>
protected virtual int[] GetStartingTraversalCost(int start)
{
int[] subset = new int[TotalNodeCount];
for (int i = 0; i < subset.Length; i++)
subset[i] = int.MaxValue; // all are unreachable
subset[start] = 0; // zero cost from start to start
foreach (int nearby in Hint(start))
subset[nearby] = TraversalCost(start, nearby);
return subset;
}

/// <summary>
/// Joins the elements of an array into a string, using
/// a given separator.
/// </summary>
/// <typeparam name="T">The type of element in the array.</typeparam>
/// <param name="separator">The seperator to insert between each element.</param>
/// <param name="array">The array.</param>
/// <returns>The resulting string.</returns>
/// <remarks>
/// This is very much like <see cref="string.Join"/>, except
/// that it works on arrays of non-strings.
/// </remarks>
protected string ArrayToString<T>(string separator, IEnumerable<int> array)
{
StringBuilder sb = new StringBuilder();
foreach (int t in array)
sb.AppendFormat("{0}{1}", t < int.MaxValue ? t + 1 : t, separator);
sb.Length -= separator.Length;
return sb.ToString();
}

}
}

The course I wrote this class for wanted me to add this algorithm to a simple graphics program that would "cut out" shapes using the shortest cost path. Here are the intelligent scissors that I wrote:

using System; 
using System.Diagnostics;
using System.Collections.Generic;
using System.Drawing;

namespace VisualIntelligentScissors
{
public class DijkstraScissors : Scissors
{
public DijkstraScissors() { }
public DijkstraScissors(GrayBitmap image, Bitmap overlay) : base(image, overlay) { }

int[,] traversalCost;
Rectangle relevantRegion;
protected int relevantPixelsCount
{
get { return relevantRegion.Width * relevantRegion.Height; }
}

public override void Trace(IList<Point> points, Pen pen)
{
if (Image == null) throw new InvalidOperationException("Set Image property first.");

using (Graphics g = Graphics.FromImage(Overlay))
{
for (int i = 0; i < points.Count; i++)
{
// Our segment travels from start to finish
Point start = points[i];
Point finish = points[(i + 1) % points.Count];

// Consider only some nearby region, to speed up processing.
relevantRegion = GetRelevantRegion(start, finish);
// Find the cost of moving to any pixel.
traversalCost = GetTraversalCost();

// Calculate what the array indexes are for the two known pixels
int startIndex = GetArrayIndex(start);
int finishIndex = GetArrayIndex(finish);

Dijkstra dijkstra = new Dijkstra(
relevantPixelsCount,
new Dijkstra.InternodeTraversalCost(getInternodeTraversalCost),
new Dijkstra.NearbyNodesHint(nearbyNodesHint)
);
int[] minimumPath = dijkstra.GetMinimumPath(startIndex, finishIndex);

// By now we should have found the best path between start and finish,
// considering all within the designated relevantRegion.
drawMinimumPath(minimumPath, pen.Color);

//g.DrawRectangle(Pens.Green, relevantRegion);
Program.MainForm.RefreshImage();
System.Windows.Forms.Application.DoEvents();
}
}
}

private void drawMinimumPath(int[] path, Color color)
{
// Show user goal point.
Point finish = GetPointFromArrayIndex(path[path.Length-1]);
Overlay.SetPixel(finish.X, finish.Y, color);

// Draw entire path
foreach (int index in path)
{
Point point = GetPointFromArrayIndex(index);
Overlay.SetPixel(point.X, point.Y, color);
//Program.MainForm.RefreshImage();
//System.Windows.Forms.Application.DoEvents();
}
}

private Rectangle GetRelevantRegion(Point start, Point finish)
{
const int minimumSpace = 5;
const float expansion = 0.01F;

Rectangle rect = Rectangle.FromLTRB(
Math.Min(start.X, finish.X),
Math.Min(start.Y, finish.Y),
Math.Max(start.X, finish.X),
Math.Max(start.Y, finish.Y)
);
rect.Inflate(Math.Max((int)(rect.Width * expansion), minimumSpace),
Math.Max((int)(rect.Height * expansion), minimumSpace));
// Make sure our relevant region stays within the bounds or calculating a gradient.
rect.Intersect(Rectangle.FromLTRB(1, 1, Image.Bitmap.Width - 1, Image.Bitmap.Height - 1));
Debug.Assert(rect.Contains(start), "Relevant region does not contain start point.");
Debug.Assert(rect.Contains(finish), "Relevant region does not contain finish point.");
return rect;
}

private int GetArrayIndex(Point point)
{
if (!relevantRegion.Contains(point)) return -1;
Point offset = point;
offset.Offset(-relevantRegion.X, -relevantRegion.Y); // remove effect of offset region
return offset.Y * relevantRegion.Width + offset.X;
}
private Point GetPointFromArrayIndex(int index)
{
Point point = new Point(index % relevantRegion.Width, index / relevantRegion.Width);
point.Offset(relevantRegion.Location);
return point;
}

private int[] GetPixelWeights()
{
int[] weights = new int[relevantPixelsCount];
for (int i = 0; i < weights.Length; i++)
weights[i] = GetPixelWeight(GetPointFromArrayIndex(i));
return weights;
}
const int maximumNearbyPositions = 4;
enum NearbyPosition : int
{
Above = 0,
Left,
Right,
Below
}
private int GetNearbyPixel(int origin, NearbyPosition relative)
{
return GetArrayIndex(GetNearbyPixel(GetPointFromArrayIndex(origin), relative));
}
private Point GetNearbyPixel(Point origin, NearbyPosition relative)
{
Point offset = origin;
switch (relative)
{
case NearbyPosition.Above:
offset.Offset(0, -1);
break;
case NearbyPosition.Below:
offset.Offset(0, 1);
break;
case NearbyPosition.Left:
offset.Offset(-1, 0);
break;
case NearbyPosition.Right:
offset.Offset(1, 0);
break;
default:
throw new NotSupportedException();
}
return offset;
}
private int GetRelativePosition(int start, int finish)
{
Point startPoint = GetPointFromArrayIndex(start);
Point finishPoint = GetPointFromArrayIndex(finish);
foreach (NearbyPosition position in Enum.GetValues(typeof(NearbyPosition)))
if (GetNearbyPixel(start, position) == finish)
return (int)position;
return -1;
}
private int[,] GetTraversalCost()
{
int[] weights = GetPixelWeights();
int[,] cost = new int[relevantPixelsCount, maximumNearbyPositions];
for (int i = 0; i < weights.Length; i++)
{
Point origin = GetPointFromArrayIndex(i);
foreach (NearbyPosition relativePosition in Enum.GetValues(typeof(NearbyPosition)))
{
Point relative = GetNearbyPixel(origin, relativePosition);
if (relevantRegion.Contains(relative))
{
int j = GetArrayIndex(relative);
cost[i, (int)relativePosition] = weights[j];
}
}
}
return cost;
}

private IEnumerable<int> nearbyNodesHint(int startingNode)
{
List<int> nearbyNodes = new List<int>(maximumNearbyPositions);
foreach (NearbyPosition position in Enum.GetValues(typeof(NearbyPosition)))
nearbyNodes.Add(GetNearbyPixel(startingNode, position));
return nearbyNodes;
}
private int getInternodeTraversalCost(int start, int finish)
{
int relativePosition = GetRelativePosition(start, finish);
if (relativePosition < 0) return int.MaxValue;
return traversalCost[start, relativePosition];
}
}
}

There are other dependencies not included here (such as the Scissors base class).  My purpose in this blog is to publish a generalized Dijkstra algorithm and give an example of how to use it.  If you would like the full source code, contact me to get me to email the code to you.  My BYU professor doesn't want BYU CS students getting the entire solution to their assigned projects.

[Updated 12/14/06]: The full source code is useful if you need a sample of how to apply the above algorithm in your own app.  Due to large demand for the source code and my limited resources to manually send it out (I can't just link to it here since my BYU professor doesn't want BYU CS students getting the entire solution to their assigned projects) I now charge a small processing fee of $5 to send you the source code.  If you would like the full source code, PayPal $5 to me and include your email address to get me to email the source code to you.  [Updated 8/30/07]: Do not use your credit card as the source of the $5 to send me PayPal money as PayPal will happily take half of the money for itself.  If you use your credit card, I'll reject payment and ask you to transfer money to your PayPal account from your bank account first.

You can also download the compiled assembly for free:

[Updated 3/16/07]: Comments now closed, due to people not reading the previous paragraph and still posting comments asking for the full source.

Tuesday, January 24, 2006

Why Equals and GetHashCode are so important to NHibernate

So I have been applying NHibernate.Generics to all my projects.  I have learned some valuable lessons.

NHibernate has always warned that all entity classes should implement their own Equals and GetHashCode methods.  I've procrastinated implementing them because NHibernate seemed to work just fine without them.  Then I started using proxy classes for lazy loading, and problems started popping up with objects comparing themselves against each other and deciding they were not equal when in fact they represented the same entity.  Lately while applying the new NHibernate.Generics collections, I found that if I had a collection of two entities, there would be three entities in the collection, where one of the entities was in there twice!  My first reaction was "Ugh!  This collection class is buggy!"  Then I remembered my neglected absence of GetHashCode and Equals.  Without them, how is a collection to know that two objects (where one is only a proxy class) are actually the same entity?

So here is the code I hurriedly injected into every last one of my entity classes:
/// 
/// Tests whether this and another object are equal in a way that
/// will still pass when proxy objects are being used.
///

public override bool Equals(object obj)
{
ClassNameHere other = obj as ClassNameHere;
if (other == null) return false;
if (Id == 0 && other.Id == 0)
return (object)this == other;
else
return Id == other.Id;
}

public override int GetHashCode()
{
if (Id == 0) return base.GetHashCode();
string stringRepresentation =
System.Reflection.MethodBase.GetCurrentMethod().DeclaringType.FullName
+ "#" + Id.ToString();
return stringRepresentation.GetHashCode();
}
With these two methods, my problems disappeared!  I even went so far as to write a reusable Code Snippet that would assist me in setting the class name that must appear in each segment.  I include the snippet file here:

xml version="1.0" encoding="utf-8" ?>
<CodeSnippets xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
<CodeSnippet Format="1.0.0">
<Header>
<Title>Entity Equals and GetHashCodeTitle>
<Shortcut>nheqShortcut>
<Author>Andrew ArnottAuthor>
<SnippetTypes>
<SnippetType>ExpansionSnippetType>
SnippetTypes>
<Description>Inserts the Equals and GetHashCode methods that NHibernate requires to run correctly.Description>
Header>
<Snippet>
<Code Language="CSharp">
[CDATA[ /// <summary>
/// Tests whether this and another object are equal in a way that
/// will still pass when proxy objects are being used.
/// summary>
public override bool Equals(object obj)
{
$type$ other = obj as $type$;
if (other == null) return false;
if (Id == 0 && other.Id == 0)
return (object)this == other;
else
return Id == other.Id;
}

public override int GetHashCode()
{
if (Id == 0) return base.GetHashCode();
string stringRepresentation =
System.Reflection.MethodBase.GetCurrentMethod().DeclaringType.FullName
+ "#" + Id.ToString();
return stringRepresentation.GetHashCode();
}

]]>
Code>
<Declarations>
<Literal Editable="true">
<ID>typeID>
<ToolTip>The name of the class being injected.ToolTip>
<Default>ClassNameDefault>
Literal>
Declarations>
Snippet>
CodeSnippet>
CodeSnippets>
By the way, I'm not too proud of my implementation of GetHashCode().  If you know a better way, please comment on the blog and let me know.

Sunday, January 22, 2006

VB.NET web scripting client library goes open source

I'm working on a ward photo directory project, for which I needed a web client scripting library.  The idea was to be able to programmatically masquerade as a web browser, pull down pages, navigate through links and fill out forms without using a web browser.  A lot of searching on the Internet turned up nothing promising for what I needed.  If you know of a good one that looks like what I've started, please point me to it if it's further along.

In the meantime, this recent startup of a project has been very useful to me.  If you need to write scripts or programs that peruse the web, parse tags and postback forms, this library is worth looking at.

The license is not indicated in the source files yet.  So I announce here that I am releasing it with the LGPL license.

I opened a new forum for the project.  And you can download the source code.

Thursday, January 19, 2006

SPSS .NET wrapper assembly goes open source

I wrote a managed C# wrapper for the spssio32.dll library that comes with SPSS.  It exposes most of the functionality of spssio32.dll for .NET use.  All the memory management required to use spssio32.dll is already taken care of, and a small set of common document operations for reading and creating files is even offered in an object-oriented class layer. 

You can download the source and visit the forum on the CodePlex project site.  [28Feb07 Update: It's released under the LGPL.] [17Apr08 update: updated links to new hosting location]

Note that due to SPSS copyright, the project does not include spssio32.dll, so you will need to get that file to use with this library in order to read and write SPSS files using .NET.

Monday, January 16, 2006

MyNHibernateContrib gets an example web app

My open-source MyNHibernateContrib project, which adds support for generics, two-way collections and nullables to NHibernate, finally got a decent example web app.  It is very small (as good example apps should be), just large enough to demo each feature. 

I still hope that the NHibernate community will integrate this library into NHibernateContrib's .NET 2.0 version once they start one.  Of course, if they add support for these features directly into NH itself, I'll be more than happy to retire my library.

The generic, two-way collection coding pattern when using this library has improved from several months ago, but it still requires a few lines of plumbing code that's not the most intuitive.  Copy and paste from the example app.  And if you know a better way to make it work, please let me know!

Friday, January 06, 2006

EMAR gets a new name, prepares for LGPL

The EMAR project that I have been working on for about four years is finally getting a (decent) new name. NAssess.



This is in preparation for releasing EMAR with the LGPL. I'll post again once the project is hosted on Novell Forge.



I'm trying to decide how to publish NAssess for open-source development. Specifically, should I put the entire SVN revision history into the public domain, or just the latest version? I would just put the whole history up there without question, except that (even now) EMAR is hooked into several in-house projects that will not be open-sourced, and they all share a repository. If I extract EMAR's thread of history from the repository and upload that, then none of the previous revisions will build anyway. Whereas the final version I will publish will have all dependencies removed, and so will build just fine. Finally, possessing the revision history for the project is a form of proof of ownership. Yes, I'm LGPL'ing it, but I'd like to prove authorship if needed down the road.



Thoughts, anyone? This will be my largest open-source contribution so far (and perhaps ever). I want it to be meaningful to people, and to do things right.

Added Jan 19, 2006: NAssess is now available under the LGPL.  Download the source from Novell Forge or visit the forum.