///|
/// Production-oriented visual quality metrics for generated artwork.
pub(all) struct DesignQualityMetrics {
canvas_area : Double
occupied_area : Double
coverage : Double
path_count : Int
layer_count : Int
total_length : Double
edge_density : Double
color_diversity : Double
contrast : Double
balance_x : Double
balance_y : Double
score : Double
} derive(Eq, Debug, ToJson)
///|
pub(all) struct MotifQualityEntry {
name : String
area : Double
perimeter : Double
density : Double
contrast : Double
score : Double
} derive(Eq, Debug, ToJson)
///|
pub(all) struct RasterQualityMetrics {
width : Int
height : Int
non_empty : Int
occupancy : Double
mean_luminance : Double
luminance_span : Double
edge_pixels : Int
edge_density : Double
connected_regions : Int
score : Double
} derive(Eq, Debug, ToJson)
///|
pub(all) struct SymmetryQuality {
horizontal : Double
vertical : Double
diagonal : Double
radial : Double
best : Double
} derive(Eq, Debug, ToJson)
///|
fn dq_clamp(value : Double) -> Double {
value.max(0.0).min(1.0)
}
///|
pub fn path_quality_area(path : Path, subdivisions : Int) -> Double {
let points = path.flatten(subdivisions=subdivisions.max(1))
polygon_area(points).abs()
}
///|
pub fn path_quality_perimeter(
path : Path,
subdivisions : Int,
closed : Bool,
) -> Double {
let points = path.flatten(subdivisions=subdivisions.max(1))
if closed {
polygon_perimeter(points)
} else if points.length() < 2 {
0.0
} else {
let mut total = 0.0
for index in 0..<(points.length() - 1) {
total += points[index].distance(points[index + 1])
}
total
}
}
///|
pub fn path_quality_density(
path : Path,
bounds : Rect,
subdivisions : Int,
) -> Double {
let area = path_quality_area(path, subdivisions)
let canvas = (bounds.width * bounds.height).max(0.0001)
dq_clamp(area / canvas)
}
///|
pub fn path_quality_center(path : Path, subdivisions : Int) -> Vec2 {
let points = path.flatten(subdivisions=subdivisions.max(1))
if points.length() == 0 {
Vec2::zero()
} else {
let mut x = 0.0
let mut y = 0.0
for point in points {
x += point.x
y += point.y
}
Vec2::new(x / points.length().to_double(), y / points.length().to_double())
}
}
///|
pub fn motif_quality_entry(
motif : Motif,
bounds : Rect,
background : Color,
) -> MotifQualityEntry {
let path = motif.transformed_path()
let area = path_quality_area(path, 8)
let perimeter = path_quality_perimeter(path, 8, true)
let density = path_quality_density(path, bounds, 8)
let contrast = Color::rgba(0.5, 0.5, 0.5).contrast_ratio(background)
let score = dq_clamp(
0.25 * density +
0.4 * (contrast / 7.0).min(1.0) +
0.35 * (perimeter / bounds.width.max(bounds.height).max(1.0)).min(1.0),
)
{ name: motif.name, area, perimeter, density, contrast, score }
}
///|
pub fn layer_quality_entries(
layer : PatternLayer,
bounds : Rect,
background : Color,
) -> Array[MotifQualityEntry] {
let result = []
for motif in layer.motifs {
result.push(motif_quality_entry(motif, bounds, background))
}
result
}
///|
pub fn composition_quality_entries(
composition : Composition,
) -> Array[MotifQualityEntry] {
let result = []
let bounds = Rect::new(0.0, 0.0, composition.width, composition.height)
for layer in composition.layers {
for entry in layer_quality_entries(layer, bounds, composition.background) {
result.push(entry)
}
}
result
}
///|
pub fn quality_color_diversity(composition : Composition) -> Double {
let colors = []
for layer in composition.layers {
for motif in layer.motifs {
if !colors.contains(motif.style.fill) {
colors.push(motif.style.fill)
}
}
}
if composition.layers.length() == 0 {
0.0
} else {
(colors.length().to_double() /
composition.layers
.fold(init=0, fn(total, layer) { total + layer.motifs.length() })
.max(1)
.to_double()).min(1.0)
}
}
///|
pub fn quality_path_length(composition : Composition) -> Double {
let mut total = 0.0
for layer in composition.layers {
for motif in layer.motifs {
total += path_quality_perimeter(motif.transformed_path(), 6, false)
}
}
total
}
///|
pub fn quality_bounds_of_composition(composition : Composition) -> Rect {
let bounds = Rect::new(0.0, 0.0, composition.width, composition.height)
let mut current = Rect::new(0.0, 0.0, 0.0, 0.0)
let mut initialized = false
for layer in composition.layers {
for motif in layer.motifs {
let next = bounds_of_points(
motif.transformed_path().flatten(subdivisions=6),
)
if !initialized {
current = next
initialized = true
} else {
current = current.union(next)
}
}
}
if initialized {
current.intersect(bounds)
} else {
current
}
}
///|
pub fn quality_balance(composition : Composition) -> (Double, Double) {
let center = Vec2::new(composition.width / 2.0, composition.height / 2.0)
let mut mass = 0.0
let mut x = 0.0
let mut y = 0.0
for layer in composition.layers {
for motif in layer.motifs {
let path = motif.transformed_path()
let area = path_quality_area(path, 5).max(0.1)
let point = path_quality_center(path, 5)
mass += area
x += point.x * area
y += point.y * area
}
}
if mass == 0.0 {
(0.0, 0.0)
} else {
(
(x / mass - center.x) / composition.width.max(1.0),
(y / mass - center.y) / composition.height.max(1.0),
)
}
}
///|
pub fn quality_symmetry_points(
points : Array[Vec2],
bounds : Rect,
) -> SymmetryQuality {
if points.length() == 0 {
{ horizontal: 1.0, vertical: 1.0, diagonal: 1.0, radial: 1.0, best: 1.0 }
} else {
let center = bounds.center()
let threshold = bounds.width.max(bounds.height).max(1.0)
let mut horizontal = 0.0
let mut vertical = 0.0
let mut diagonal = 0.0
let mut radial = 0.0
for point in points {
let mirrored_h = Vec2::new(point.x, 2.0 * center.y - point.y)
let mirrored_v = Vec2::new(2.0 * center.x - point.x, point.y)
let mirrored_d = Vec2::new(
center.x + point.y - center.y,
center.y + point.x - center.x,
)
let mirrored_r = Vec2::new(
2.0 * center.x - point.x,
2.0 * center.y - point.y,
)
let mut best_h = threshold
let mut best_v = threshold
let mut best_d = threshold
let mut best_r = threshold
for candidate in points {
best_h = best_h.min(mirrored_h.distance(candidate))
best_v = best_v.min(mirrored_v.distance(candidate))
best_d = best_d.min(mirrored_d.distance(candidate))
best_r = best_r.min(mirrored_r.distance(candidate))
}
horizontal += 1.0 - (best_h / threshold).min(1.0)
vertical += 1.0 - (best_v / threshold).min(1.0)
diagonal += 1.0 - (best_d / threshold).min(1.0)
radial += 1.0 - (best_r / threshold).min(1.0)
}
let n = points.length().to_double()
{
horizontal: horizontal / n,
vertical: vertical / n,
diagonal: diagonal / n,
radial: radial / n,
best: horizontal.max(vertical).max(diagonal).max(radial) / n,
}
}
}
///|
pub fn composition_symmetry(composition : Composition) -> SymmetryQuality {
let points = []
for layer in composition.layers {
for motif in layer.motifs {
points.push(path_quality_center(motif.transformed_path(), 5))
}
}
quality_symmetry_points(
points,
Rect::new(0.0, 0.0, composition.width, composition.height),
)
}
///|
pub fn composition_quality_metrics(
composition : Composition,
) -> DesignQualityMetrics {
let canvas_area = (composition.width * composition.height).max(0.0)
let bounds = quality_bounds_of_composition(composition)
let occupied = (bounds.width * bounds.height).min(canvas_area)
let entries = composition_quality_entries(composition)
let mut edge_density = 0.0
let mut contrast = 0.0
for entry in entries {
edge_density += entry.perimeter
contrast += entry.contrast
}
let path_count = composition.layers.fold(init=0, fn(total, layer) {
total + layer.motifs.length()
})
let (balance_x, balance_y) = quality_balance(composition)
let normalized_edges = (edge_density / canvas_area.max(1.0).sqrt()).min(1.0)
let normalized_contrast = if path_count == 0 {
0.0
} else {
(contrast / path_count.to_double() / 7.0).min(1.0)
}
let coverage = if canvas_area == 0.0 { 0.0 } else { occupied / canvas_area }
let score = dq_clamp(
0.3 * coverage +
0.2 * normalized_edges +
0.2 * normalized_contrast +
0.2 * quality_color_diversity(composition) +
0.1 * (1.0 - balance_x.abs().min(1.0) - balance_y.abs().min(1.0)).max(0.0),
)
{
canvas_area,
occupied_area: occupied,
coverage,
path_count,
layer_count: composition.layers.length(),
total_length: quality_path_length(composition),
edge_density: normalized_edges,
color_diversity: quality_color_diversity(composition),
contrast: normalized_contrast,
balance_x,
balance_y,
score,
}
}
///|
pub fn design_quality_report_text(metrics : DesignQualityMetrics) -> String {
"canvas=" +
metrics.canvas_area.to_string() +
" occupied=" +
metrics.occupied_area.to_string() +
" coverage=" +
metrics.coverage.to_string() +
" paths=" +
metrics.path_count.to_string() +
" layers=" +
metrics.layer_count.to_string() +
" length=" +
metrics.total_length.to_string() +
" edges=" +
metrics.edge_density.to_string() +
" colors=" +
metrics.color_diversity.to_string() +
" contrast=" +
metrics.contrast.to_string() +
" balance=" +
metrics.balance_x.to_string() +
"," +
metrics.balance_y.to_string() +
" score=" +
metrics.score.to_string()
}
///|
pub fn design_quality_report_csv(metrics : DesignQualityMetrics) -> String {
"canvas_area,occupied_area,coverage,path_count,layer_count,total_length,edge_density,color_diversity,contrast,balance_x,balance_y,score\n" +
metrics.canvas_area.to_string() +
"," +
metrics.occupied_area.to_string() +
"," +
metrics.coverage.to_string() +
"," +
metrics.path_count.to_string() +
"," +
metrics.layer_count.to_string() +
"," +
metrics.total_length.to_string() +
"," +
metrics.edge_density.to_string() +
"," +
metrics.color_diversity.to_string() +
"," +
metrics.contrast.to_string() +
"," +
metrics.balance_x.to_string() +
"," +
metrics.balance_y.to_string() +
"," +
metrics.score.to_string() +
"\n"
}
///|
pub fn raster_quality_metrics(
raster : Raster,
threshold : Double,
) -> RasterQualityMetrics {
let total = raster.pixels.length()
let limit = threshold.max(0.0).min(1.0)
let mut non_empty = 0
let mut lum = 0.0
let mut low = 1.0
let mut high = 0.0
for color in raster.pixels {
if color.a > 0.001 && color.luminance() > limit {
non_empty += 1
}
let value = color.luminance()
lum += value
low = low.min(value)
high = high.max(value)
}
let edges = raster.sobel_edges()
let mut edge_pixels = 0
for color in edges.pixels {
if color.r > 0.5 {
edge_pixels += 1
}
}
let edge_density = if total == 0 {
0.0
} else {
edge_pixels.to_double() / total.to_double()
}
let regions = raster.connected_components(limit).length()
let occupancy = if total == 0 {
0.0
} else {
non_empty.to_double() / total.to_double()
}
let score = dq_clamp(
0.35 * occupancy +
0.25 * (high - low) +
0.2 * (1.0 - edge_density).max(0.0) +
0.2 * (regions.min(20).to_double() / 20.0),
)
{
width: raster.width,
height: raster.height,
non_empty,
occupancy,
mean_luminance: if total == 0 {
0.0
} else {
lum / total.to_double()
},
luminance_span: high - low,
edge_pixels,
edge_density,
connected_regions: regions,
score,
}
}
///|
pub fn raster_quality_report_text(metrics : RasterQualityMetrics) -> String {
"size=" +
metrics.width.to_string() +
"x" +
metrics.height.to_string() +
" non_empty=" +
metrics.non_empty.to_string() +
" occupancy=" +
metrics.occupancy.to_string() +
" mean_luminance=" +
metrics.mean_luminance.to_string() +
" span=" +
metrics.luminance_span.to_string() +
" edge_pixels=" +
metrics.edge_pixels.to_string() +
" edge_density=" +
metrics.edge_density.to_string() +
" regions=" +
metrics.connected_regions.to_string() +
" score=" +
metrics.score.to_string()
}
///|
pub fn raster_quality_report_csv(metrics : RasterQualityMetrics) -> String {
"width,height,non_empty,occupancy,mean_luminance,luminance_span,edge_pixels,edge_density,connected_regions,score\n" +
metrics.width.to_string() +
"," +
metrics.height.to_string() +
"," +
metrics.non_empty.to_string() +
"," +
metrics.occupancy.to_string() +
"," +
metrics.mean_luminance.to_string() +
"," +
metrics.luminance_span.to_string() +
"," +
metrics.edge_pixels.to_string() +
"," +
metrics.edge_density.to_string() +
"," +
metrics.connected_regions.to_string() +
"," +
metrics.score.to_string() +
"\n"
}
///|
pub fn quality_compare_documents(
left : DesignDocument,
right : DesignDocument,
) -> (Double, Double, Double) {
let left_metrics = composition_quality_metrics(left.composition)
let right_metrics = composition_quality_metrics(right.composition)
(
right_metrics.score - left_metrics.score,
right_metrics.coverage - left_metrics.coverage,
right_metrics.contrast - left_metrics.contrast,
)
}
///|
pub fn quality_rank_compositions(
compositions : Array[Composition],
) -> Array[(Int, Double)] {
let result = []
for index, composition in compositions {
result.push((index, composition_quality_metrics(composition).score))
}
for i in 1.. 0 && result[position - 1].1 < value.1 {
result[position] = result[position - 1]
position -= 1
}
result[position] = value
}
result
}
///|
pub fn quality_threshold_pass(
metrics : DesignQualityMetrics,
minimum_score : Double,
minimum_coverage : Double,
) -> Bool {
metrics.score >= minimum_score && metrics.coverage >= minimum_coverage
}
///|
pub fn raster_threshold_pass(
metrics : RasterQualityMetrics,
minimum_score : Double,
minimum_occupancy : Double,
) -> Bool {
metrics.score >= minimum_score && metrics.occupancy >= minimum_occupancy
}