mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-17 05:52:39 +00:00
531 lines
19 KiB
C++
531 lines
19 KiB
C++
#include "ColorDecomposeRecipe.hpp"
|
|
|
|
#include "FilamentMixer.hpp"
|
|
#include "Utils.hpp"
|
|
#include "nlohmann/json.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <fstream>
|
|
#include <limits>
|
|
#include <utility>
|
|
|
|
namespace Slic3r {
|
|
namespace {
|
|
|
|
struct LabColor {
|
|
double l{0.0};
|
|
double a{0.0};
|
|
double b{0.0};
|
|
};
|
|
|
|
struct StandardRecipeEntry {
|
|
ColorDecomposeRecipeMode mode{ColorDecomposeRecipeMode::CMYW};
|
|
std::string material;
|
|
std::string source;
|
|
std::vector<std::string> component_keys;
|
|
std::vector<std::string> component_hexes;
|
|
std::vector<int> ratios;
|
|
std::string measured_hex;
|
|
LabColor measured_lab;
|
|
};
|
|
|
|
static double srgb_to_linear(double v)
|
|
{
|
|
v /= 255.0;
|
|
return v <= 0.04045 ? v / 12.92 : std::pow((v + 0.055) / 1.055, 2.4);
|
|
}
|
|
|
|
static double xyz_to_lab_component(double v)
|
|
{
|
|
constexpr double eps = 216.0 / 24389.0;
|
|
constexpr double kappa = 24389.0 / 27.0;
|
|
return v > eps ? std::cbrt(v) : (kappa * v + 16.0) / 116.0;
|
|
}
|
|
|
|
static LabColor rgb_to_lab(const ColorDecomposeRgb& rgb)
|
|
{
|
|
const double r = srgb_to_linear(rgb.r);
|
|
const double g = srgb_to_linear(rgb.g);
|
|
const double b = srgb_to_linear(rgb.b);
|
|
|
|
const double x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047;
|
|
const double y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b);
|
|
const double z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883;
|
|
|
|
const double fx = xyz_to_lab_component(x);
|
|
const double fy = xyz_to_lab_component(y);
|
|
const double fz = xyz_to_lab_component(z);
|
|
|
|
return {116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)};
|
|
}
|
|
|
|
static std::string lab_to_srgb_hex(const LabColor& lab)
|
|
{
|
|
constexpr double Xn = 0.95047, Yn = 1.0, Zn = 1.08883;
|
|
|
|
auto f_inv = [](double t) -> double {
|
|
constexpr double eps = 216.0 / 24389.0;
|
|
constexpr double kappa = 24389.0 / 27.0;
|
|
const double t3 = t * t * t;
|
|
return t3 > eps ? t3 : (t * 116.0 - 16.0) / kappa;
|
|
};
|
|
|
|
const double fy = (lab.l + 16.0) / 116.0;
|
|
const double fx = lab.a / 500.0 + fy;
|
|
const double fz = fy - lab.b / 200.0;
|
|
|
|
const double X = Xn * f_inv(fx);
|
|
const double Y = Yn * f_inv(fy);
|
|
const double Z = Zn * f_inv(fz);
|
|
|
|
double r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z;
|
|
double g = -0.9689 * X + 1.8758 * Y + 0.0415 * Z;
|
|
double b = 0.0557 * X - 0.2040 * Y + 1.0570 * Z;
|
|
|
|
auto gamma = [](double c) -> double {
|
|
c = std::max(0.0, std::min(1.0, c));
|
|
return c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055;
|
|
};
|
|
auto u8 = [&](double c) -> int {
|
|
return std::max(0, std::min(255, static_cast<int>(std::lround(gamma(c) * 255.0))));
|
|
};
|
|
|
|
char buf[8];
|
|
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", u8(r), u8(g), u8(b));
|
|
return std::string(buf);
|
|
}
|
|
|
|
static double delta_e76(const LabColor& a, const LabColor& b)
|
|
{
|
|
return std::sqrt(std::pow(a.l - b.l, 2.0) + std::pow(a.a - b.a, 2.0) + std::pow(a.b - b.b, 2.0));
|
|
}
|
|
|
|
static bool material_matches(const std::string& a, const std::string& b)
|
|
{
|
|
if (a.empty() || b.empty())
|
|
return false;
|
|
return a == b || a == b + " Basic" || b == a + " Basic";
|
|
}
|
|
|
|
static std::vector<std::vector<int>> ratio_grid(size_t n)
|
|
{
|
|
std::vector<std::vector<int>> out;
|
|
if (n == 2) {
|
|
for (int a = 20; a <= 80; a += 5)
|
|
out.push_back({a, 100 - a});
|
|
} else if (n == 3) {
|
|
for (int a = 20; a <= 60; a += 5)
|
|
for (int b = 20; b <= 80 - a; b += 5) {
|
|
const int c = 100 - a - b;
|
|
if (c >= 20)
|
|
out.push_back({a, b, c});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
static ColorDecomposeRecipeMode parse_mode(const std::string& s)
|
|
{
|
|
if (s == "RYBW" || s == "RGBY")
|
|
return ColorDecomposeRecipeMode::RYBW;
|
|
return ColorDecomposeRecipeMode::CMYW;
|
|
}
|
|
|
|
static std::vector<StandardRecipeEntry> load_standard_entries()
|
|
{
|
|
std::vector<StandardRecipeEntry> entries;
|
|
const std::string path = resources_dir() + "/filament_mixing/standard_color_recipes.json";
|
|
std::ifstream ifs(path);
|
|
if (!ifs)
|
|
return entries;
|
|
|
|
nlohmann::json root = nlohmann::json::parse(ifs, nullptr, false);
|
|
if (root.is_discarded() || !root.contains("entries") || !root["entries"].is_array())
|
|
return entries;
|
|
|
|
for (const auto& item : root["entries"]) {
|
|
if (!item.is_object())
|
|
continue;
|
|
StandardRecipeEntry entry;
|
|
entry.mode = parse_mode(item.value("mode", "CMYW"));
|
|
entry.material = item.value("material", "");
|
|
entry.source = item.value("source", "");
|
|
entry.measured_hex = item.value("measured_rgb", "");
|
|
|
|
if (item.contains("components") && item["components"].is_array()) {
|
|
for (const auto& comp : item["components"]) {
|
|
if (comp.is_object()) {
|
|
entry.component_keys.push_back(comp.value("key", ""));
|
|
entry.component_hexes.push_back(comp.value("rgb", ""));
|
|
}
|
|
}
|
|
}
|
|
if (item.contains("ratios") && item["ratios"].is_array()) {
|
|
for (const auto& ratio : item["ratios"]) {
|
|
if (ratio.is_number_integer())
|
|
entry.ratios.push_back(ratio.get<int>());
|
|
}
|
|
}
|
|
if (item.contains("measured_lab") && item["measured_lab"].is_array() && item["measured_lab"].size() >= 3) {
|
|
entry.measured_lab = {
|
|
item["measured_lab"][0].get<double>(),
|
|
item["measured_lab"][1].get<double>(),
|
|
item["measured_lab"][2].get<double>()
|
|
};
|
|
} else {
|
|
ColorDecomposeRgb measured_rgb;
|
|
if (!color_decompose_hex_to_rgb(entry.measured_hex, measured_rgb))
|
|
continue;
|
|
entry.measured_lab = rgb_to_lab(measured_rgb);
|
|
}
|
|
|
|
if (entry.component_hexes.size() >= 2 && entry.component_hexes.size() == entry.ratios.size() &&
|
|
!entry.measured_hex.empty())
|
|
entries.push_back(std::move(entry));
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
static const std::vector<StandardRecipeEntry>& standard_entries()
|
|
{
|
|
static const std::vector<StandardRecipeEntry> entries = load_standard_entries();
|
|
return entries;
|
|
}
|
|
|
|
static void evaluate_candidate(const ColorDecomposeRgb& target,
|
|
const std::vector<std::string>& hexes,
|
|
const std::vector<int>& ratios,
|
|
const std::vector<unsigned int>& indices,
|
|
ColorDecomposeRecipeMode mode,
|
|
double& best_score,
|
|
ColorDecomposeRecipeResult& best)
|
|
{
|
|
const std::string mixed = blend_color_multi(hexes, ratios);
|
|
ColorDecomposeRgb mixed_rgb;
|
|
if (!color_decompose_hex_to_rgb(mixed, mixed_rgb))
|
|
return;
|
|
|
|
const double score = delta_e76(rgb_to_lab(target), rgb_to_lab(mixed_rgb));
|
|
if (score >= best_score)
|
|
return;
|
|
|
|
best_score = score;
|
|
best.valid = true;
|
|
best.mode = mode;
|
|
best.matched_color_hex = mixed;
|
|
best.components.clear();
|
|
for (size_t i = 0; i < hexes.size(); ++i) {
|
|
ColorDecomposeRecipeComponent comp;
|
|
comp.color_hex = hexes[i];
|
|
comp.ratio = ratios[i];
|
|
comp.filament_index = i < indices.size() ? indices[i] : 0;
|
|
best.components.push_back(comp);
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string color_decompose_rgb_to_hex(const ColorDecomposeRgb& rgb)
|
|
{
|
|
char buf[8];
|
|
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b);
|
|
return std::string(buf);
|
|
}
|
|
|
|
bool color_decompose_hex_to_rgb(const std::string& hex, ColorDecomposeRgb& out)
|
|
{
|
|
if (hex.size() < 7 || hex[0] != '#')
|
|
return false;
|
|
unsigned r = 0, g = 0, b = 0;
|
|
if (std::sscanf(hex.c_str(), "#%02x%02x%02x", &r, &g, &b) != 3)
|
|
return false;
|
|
out = {static_cast<unsigned char>(r), static_cast<unsigned char>(g), static_cast<unsigned char>(b)};
|
|
return true;
|
|
}
|
|
|
|
ColorDecomposeRecipeResult recommend_from_physical_filaments(
|
|
const ColorDecomposeRgb& target,
|
|
const std::vector<ColorDecomposePhysicalFilament>& physical_filaments,
|
|
const std::string& preferred_material_type)
|
|
{
|
|
std::vector<ColorDecomposePhysicalFilament> candidates;
|
|
for (const auto& filament : physical_filaments) {
|
|
if (filament.is_mixed)
|
|
continue;
|
|
ColorDecomposeRgb ignored;
|
|
if (!color_decompose_hex_to_rgb(filament.color_hex, ignored))
|
|
continue;
|
|
if (preferred_material_type.empty() || material_matches(filament.type, preferred_material_type))
|
|
candidates.push_back(filament);
|
|
}
|
|
|
|
// Early exit: if a material-matched candidate has the exact target color,
|
|
// return it as 100%. Downstream rejects single-component results (no mixed
|
|
// slot created), which is correct -- the color already exists.
|
|
const std::string target_hex = color_decompose_rgb_to_hex(target);
|
|
for (const auto& cand : candidates) {
|
|
ColorDecomposeRgb cand_rgb;
|
|
if (!color_decompose_hex_to_rgb(cand.color_hex, cand_rgb))
|
|
continue;
|
|
if (color_decompose_rgb_to_hex(cand_rgb) == target_hex) {
|
|
ColorDecomposeRecipeResult exact;
|
|
exact.valid = true;
|
|
exact.mode = ColorDecomposeRecipeMode::MaterialList;
|
|
exact.matched_color_hex = cand.color_hex;
|
|
ColorDecomposeRecipeComponent comp;
|
|
comp.color_hex = cand.color_hex;
|
|
comp.ratio = 100;
|
|
comp.filament_index = cand.filament_index;
|
|
exact.components.push_back(comp);
|
|
return exact;
|
|
}
|
|
}
|
|
|
|
if (candidates.size() < 2)
|
|
candidates = physical_filaments;
|
|
candidates.erase(std::remove_if(candidates.begin(), candidates.end(), [](const auto& filament) {
|
|
if (filament.is_mixed)
|
|
return true;
|
|
ColorDecomposeRgb ignored;
|
|
return !color_decompose_hex_to_rgb(filament.color_hex, ignored);
|
|
}), candidates.end());
|
|
|
|
constexpr size_t kMaxCandidates = 8;
|
|
if (candidates.size() > kMaxCandidates) {
|
|
const LabColor target_lab = rgb_to_lab(target);
|
|
std::sort(candidates.begin(), candidates.end(),
|
|
[&target_lab](const ColorDecomposePhysicalFilament& a, const ColorDecomposePhysicalFilament& b) {
|
|
ColorDecomposeRgb rgb_a, rgb_b;
|
|
color_decompose_hex_to_rgb(a.color_hex, rgb_a);
|
|
color_decompose_hex_to_rgb(b.color_hex, rgb_b);
|
|
return delta_e76(target_lab, rgb_to_lab(rgb_a))
|
|
< delta_e76(target_lab, rgb_to_lab(rgb_b));
|
|
});
|
|
candidates.resize(kMaxCandidates);
|
|
}
|
|
|
|
ColorDecomposeRecipeResult best;
|
|
double best_score = std::numeric_limits<double>::max();
|
|
|
|
for (size_t i = 0; i < candidates.size(); ++i) {
|
|
for (size_t j = i + 1; j < candidates.size(); ++j) {
|
|
const std::vector<std::string> hexes = {candidates[i].color_hex, candidates[j].color_hex};
|
|
const std::vector<unsigned int> indices = {candidates[i].filament_index, candidates[j].filament_index};
|
|
for (const auto& ratios : ratio_grid(2))
|
|
evaluate_candidate(target, hexes, ratios, indices, ColorDecomposeRecipeMode::MaterialList, best_score, best);
|
|
|
|
for (size_t k = j + 1; k < candidates.size(); ++k) {
|
|
const std::vector<std::string> hexes3 = {candidates[i].color_hex, candidates[j].color_hex, candidates[k].color_hex};
|
|
const std::vector<unsigned int> indices3 = {candidates[i].filament_index, candidates[j].filament_index, candidates[k].filament_index};
|
|
for (const auto& ratios : ratio_grid(3))
|
|
evaluate_candidate(target, hexes3, ratios, indices3, ColorDecomposeRecipeMode::MaterialList, best_score, best);
|
|
}
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
ColorDecomposeRecipeResult lookup_standard_recipe(
|
|
const ColorDecomposeRgb& target,
|
|
ColorDecomposeRecipeMode mode,
|
|
const std::string& preferred_material_type)
|
|
{
|
|
const LabColor target_lab = rgb_to_lab(target);
|
|
ColorDecomposeRecipeResult best;
|
|
double best_score = std::numeric_limits<double>::max();
|
|
|
|
auto consider = [&](bool require_material_match) {
|
|
for (const StandardRecipeEntry& entry : standard_entries()) {
|
|
if (entry.mode != mode)
|
|
continue;
|
|
if (require_material_match && !material_matches(entry.material, preferred_material_type))
|
|
continue;
|
|
if (!require_material_match && !preferred_material_type.empty() && material_matches(entry.material, preferred_material_type))
|
|
continue;
|
|
|
|
const double score = delta_e76(target_lab, entry.measured_lab);
|
|
if (score >= best_score)
|
|
continue;
|
|
|
|
best_score = score;
|
|
best.valid = true;
|
|
best.mode = mode;
|
|
best.matched_color_hex = entry.measured_hex;
|
|
best.components.clear();
|
|
for (size_t i = 0; i < entry.component_hexes.size(); ++i) {
|
|
ColorDecomposeRecipeComponent comp;
|
|
comp.color_hex = entry.component_hexes[i];
|
|
comp.base_color = i < entry.component_keys.size() ? entry.component_keys[i] : "";
|
|
comp.ratio = entry.ratios[i];
|
|
comp.filament_index = 0;
|
|
best.components.push_back(comp);
|
|
}
|
|
}
|
|
};
|
|
|
|
consider(true);
|
|
if (!best.valid)
|
|
consider(false);
|
|
return best;
|
|
}
|
|
|
|
std::string lookup_measured_blend_color(const std::vector<std::string>& component_hexes,
|
|
const std::vector<int>& ratios)
|
|
{
|
|
if (component_hexes.size() < 2 || component_hexes.size() != ratios.size())
|
|
return {};
|
|
|
|
auto normalize_hex = [](const std::string& hex) -> std::string {
|
|
ColorDecomposeRgb rgb;
|
|
if (!color_decompose_hex_to_rgb(hex, rgb))
|
|
return {};
|
|
char buf[8];
|
|
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b);
|
|
return std::string(buf);
|
|
};
|
|
|
|
// Stage 1: canonicalize input by sorting (hex, ratio) pairs so matching
|
|
// is independent of the caller's component order.
|
|
const size_t n = component_hexes.size();
|
|
std::vector<std::pair<std::string, int>> in_pairs;
|
|
in_pairs.reserve(n);
|
|
for (size_t i = 0; i < n; ++i) {
|
|
std::string nh = normalize_hex(component_hexes[i]);
|
|
if (nh.empty())
|
|
return {};
|
|
in_pairs.emplace_back(std::move(nh), ratios[i]);
|
|
}
|
|
std::sort(in_pairs.begin(), in_pairs.end());
|
|
|
|
std::vector<std::string> in_hexes;
|
|
std::vector<int> in_ratios;
|
|
in_hexes.reserve(n);
|
|
in_ratios.reserve(n);
|
|
for (const auto& p : in_pairs) {
|
|
in_hexes.push_back(p.first);
|
|
in_ratios.push_back(p.second);
|
|
}
|
|
|
|
// Normalize ratios to sum=100 (callers may pass arbitrary weights,
|
|
// e.g. MixedFilamentDialog uses ratio*10000).
|
|
{
|
|
int sum = 0;
|
|
for (int r : in_ratios) sum += r;
|
|
if (sum > 0 && sum != 100) {
|
|
int new_sum = 0;
|
|
for (size_t i = 0; i < in_ratios.size(); ++i) {
|
|
in_ratios[i] = static_cast<int>(std::lround(
|
|
static_cast<double>(in_ratios[i]) * 100.0 / static_cast<double>(sum)));
|
|
new_sum += in_ratios[i];
|
|
}
|
|
if (new_sum != 100) {
|
|
auto it = std::max_element(in_ratios.begin(), in_ratios.end());
|
|
*it += (100 - new_sum);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to polynomial model for ratios outside the measured range.
|
|
{
|
|
bool out_of_range = false;
|
|
if (n == 2) {
|
|
for (int r : in_ratios)
|
|
if (r < 20 || r > 80) { out_of_range = true; break; }
|
|
} else {
|
|
for (int r : in_ratios)
|
|
if (r < 20) { out_of_range = true; break; }
|
|
}
|
|
if (out_of_range)
|
|
return {};
|
|
}
|
|
|
|
// Stage 2: collect anchors with the same component hex set; try exact match.
|
|
struct Anchor {
|
|
std::vector<int> ratios;
|
|
LabColor lab;
|
|
std::string hex;
|
|
};
|
|
std::vector<Anchor> anchors;
|
|
|
|
for (const StandardRecipeEntry& entry : standard_entries()) {
|
|
if (entry.source != "measured" && entry.source != "interpolated")
|
|
continue;
|
|
if (entry.component_hexes.size() != n)
|
|
continue;
|
|
|
|
std::vector<std::pair<std::string, int>> e_pairs;
|
|
e_pairs.reserve(n);
|
|
for (size_t i = 0; i < n; ++i)
|
|
e_pairs.emplace_back(normalize_hex(entry.component_hexes[i]), entry.ratios[i]);
|
|
std::sort(e_pairs.begin(), e_pairs.end());
|
|
|
|
bool same_set = true;
|
|
for (size_t i = 0; i < n; ++i)
|
|
if (e_pairs[i].first != in_hexes[i]) { same_set = false; break; }
|
|
if (!same_set)
|
|
continue;
|
|
|
|
Anchor a;
|
|
a.ratios.reserve(n);
|
|
for (const auto& p : e_pairs) a.ratios.push_back(p.second);
|
|
a.lab = entry.measured_lab;
|
|
a.hex = entry.measured_hex;
|
|
|
|
if (a.ratios == in_ratios)
|
|
return a.hex;
|
|
|
|
anchors.push_back(std::move(a));
|
|
}
|
|
|
|
if (anchors.size() < 2)
|
|
return {};
|
|
|
|
// Stage 3: interpolation in Lab space.
|
|
if (n == 2) {
|
|
// 1D linear interpolation along ratio[0].
|
|
std::sort(anchors.begin(), anchors.end(),
|
|
[](const Anchor& a, const Anchor& b) { return a.ratios[0] < b.ratios[0]; });
|
|
const double x = static_cast<double>(in_ratios[0]);
|
|
size_t lo = 0;
|
|
while (lo + 2 < anchors.size() && static_cast<double>(anchors[lo + 1].ratios[0]) <= x)
|
|
++lo;
|
|
const Anchor& a0 = anchors[lo];
|
|
const Anchor& a1 = anchors[lo + 1];
|
|
const double span = static_cast<double>(a1.ratios[0] - a0.ratios[0]);
|
|
const double t = span > 0.0 ? (x - static_cast<double>(a0.ratios[0])) / span : 0.0;
|
|
return lab_to_srgb_hex({a0.lab.l + t * (a1.lab.l - a0.lab.l),
|
|
a0.lab.a + t * (a1.lab.a - a0.lab.a),
|
|
a0.lab.b + t * (a1.lab.b - a0.lab.b)});
|
|
}
|
|
|
|
// 3+ color: IDW (p=2) with 3 nearest anchors in the (ratio[0], ratio[1]) plane.
|
|
const double ra = static_cast<double>(in_ratios[0]);
|
|
const double rb = static_cast<double>(in_ratios[1]);
|
|
std::vector<std::pair<double, const Anchor*>> dists;
|
|
dists.reserve(anchors.size());
|
|
for (const Anchor& a : anchors) {
|
|
const double d = std::sqrt(std::pow(ra - static_cast<double>(a.ratios[0]), 2.0) +
|
|
std::pow(rb - static_cast<double>(a.ratios[1]), 2.0));
|
|
if (d == 0.0)
|
|
return a.hex;
|
|
dists.emplace_back(d, &a);
|
|
}
|
|
const size_t k = std::min(static_cast<size_t>(3), dists.size());
|
|
std::partial_sort(dists.begin(), dists.begin() + k, dists.end(),
|
|
[](const auto& a, const auto& b) { return a.first < b.first; });
|
|
double num_l = 0.0, num_a = 0.0, num_b = 0.0, den = 0.0;
|
|
for (size_t j = 0; j < k; ++j) {
|
|
const double w = 1.0 / (dists[j].first * dists[j].first);
|
|
num_l += w * dists[j].second->lab.l;
|
|
num_a += w * dists[j].second->lab.a;
|
|
num_b += w * dists[j].second->lab.b;
|
|
den += w;
|
|
}
|
|
return lab_to_srgb_hex({num_l / den, num_a / den, num_b / den});
|
|
}
|
|
|
|
} // namespace Slic3r
|