18#include <unordered_map>
20#include <nlohmann/json.hpp>
22#include <opencv2/core.hpp>
23#include <opencv2/imgcodecs.hpp>
24#include <opencv2/imgproc.hpp>
28double parseNumber(
const std::string& text)
31 return std::stod(text);
37bool tryParseJsonObject(
const std::string& text, nlohmann::json& out)
40 out = nlohmann::json::parse(text);
41 return out.is_object();
47std::int32_t qmlHashString(
const std::string& value)
49 std::int32_t hash = 0;
50 for (
const unsigned char ch : value) {
51 hash =
static_cast<std::int32_t
>((hash << 5) - hash + ch);
53 return hash < 0 ? -hash : hash;
56const std::vector<cv::Scalar>& qmlChartPalette()
58 static const std::vector<cv::Scalar> colors = {
59 cv::Scalar(199, 211, 141, 255),
60 cv::Scalar(179, 255, 255, 255),
61 cv::Scalar(218, 186, 190, 255),
62 cv::Scalar(114, 128, 251, 255),
63 cv::Scalar(211, 177, 128, 255),
64 cv::Scalar(98, 180, 253, 255),
65 cv::Scalar(105, 222, 179, 255),
66 cv::Scalar(229, 205, 252, 255),
67 cv::Scalar(217, 217, 217, 255),
68 cv::Scalar(189, 128, 188, 255),
69 cv::Scalar(197, 235, 204, 255),
70 cv::Scalar(111, 237, 255, 255),
75cv::Scalar colorForIndex(std::size_t index)
77 const auto& colors = qmlChartPalette();
78 if (colors.empty())
return cv::Scalar(119, 170, 221, 255);
79 return colors[index % colors.size()];
82cv::Scalar colorForKey(
const std::string& key)
84 const auto& colors = qmlChartPalette();
85 if (colors.empty())
return cv::Scalar(119, 170, 221, 255);
86 const auto index =
static_cast<std::size_t
>(qmlHashString(key)) % colors.size();
87 return colorForIndex(index);
90double renderScale(
const cv::Mat& image)
92 return std::max(1.0,
static_cast<double>(image.cols) / 1280.0);
95int scaled(
const cv::Mat& image,
int value)
97 return static_cast<int>(std::round(value * renderScale(image)));
100double scaledFont(
const cv::Mat& image,
double value)
102 return value * renderScale(image);
107 return cv::FONT_HERSHEY_SIMPLEX;
110void drawRotatedText(cv::Mat& image,
111 const std::string& text,
112 const cv::Point& topLeft,
114 const cv::Scalar& color,
117 if (text.empty())
return;
120 const auto textSize = cv::getTextSize(text, chartFontFace(), fontScale, thickness, &baseline);
121 cv::Mat textImage(textSize.height + baseline + 8, textSize.width + 8, CV_8UC4, cv::Scalar(0, 0, 0, 0));
122 cv::putText(textImage,
124 cv::Point(4, textSize.height + 2),
132 cv::rotate(textImage, rotated, cv::ROTATE_90_CLOCKWISE);
134 for (
int y = 0; y < rotated.rows; ++y) {
135 const int targetY = topLeft.y + y;
136 if (targetY < 0 || targetY >= image.rows)
continue;
137 for (
int x = 0; x < rotated.cols; ++x) {
138 const int targetX = topLeft.x + x;
139 if (targetX < 0 || targetX >= image.cols)
continue;
141 const cv::Vec4b source = rotated.at<cv::Vec4b>(y, x);
142 if (source[3] == 0)
continue;
143 if (image.channels() == 4) {
144 image.at<cv::Vec4b>(targetY, targetX) = source;
146 image.at<cv::Vec3b>(targetY, targetX) = cv::Vec3b(source[0], source[1], source[2]);
162 std::vector<std::vector<std::string>> rows;
164 rows.push_back({
"Category",
"Value"});
165 for (
const auto& row : result.
table) {
166 if (row.empty())
continue;
167 if (row.size() == 1) rows.push_back({row[0],
"0"});
168 else rows.push_back({row[0], row[1]});
174 rows.push_back({
"Month",
"Total"});
175 for (
const auto& row : result.
table) {
176 if (row.empty())
continue;
177 std::string month = row[0];
178 std::string totalValue =
"0";
179 if (row.size() > 1) {
180 nlohmann::json summary;
181 if (tryParseJsonObject(row[1], summary)) {
182 if (summary.contains(kMonth) && summary[kMonth].is_string()) {
183 month = summary[
kMonth].get<std::string>();
185 if (summary.contains(kTotal) && summary[kTotal].is_number()) {
186 totalValue = std::to_string(summary[kTotal].get<double>());
192 rows.push_back({month, totalValue});
198 rows.push_back({
"Label",
"OriginalAmount",
"AdjustedAmount",
"TaxPercent",
"TaxFactor",
"TransactionId"});
199 for (
const auto& row : result.
table) {
200 if (row.empty())
continue;
202 std::string label = row[0];
203 std::string original =
"";
204 std::string adjusted =
"";
205 std::string taxPercent =
"";
206 std::string taxFactor =
"";
207 std::string txId =
"";
209 if (row.size() > 1) {
210 nlohmann::json summary;
211 if (tryParseJsonObject(row[1], summary)) {
212 if (summary.contains(kAmountOriginal) && summary[kAmountOriginal].is_number()) {
213 original = std::to_string(summary[kAmountOriginal].get<double>());
215 if (summary.contains(kAmountAdjusted) && summary[kAmountAdjusted].is_number()) {
216 adjusted = std::to_string(summary[kAmountAdjusted].get<double>());
218 if (summary.contains(kTaxPercent) && summary[kTaxPercent].is_number()) {
219 taxPercent = std::to_string(summary[kTaxPercent].get<double>());
221 if (summary.contains(kTaxFactor) && summary[kTaxFactor].is_number()) {
222 taxFactor = std::to_string(summary[kTaxFactor].get<double>());
224 if (summary.contains(kTransactionId) && summary[kTransactionId].is_string()) {
230 rows.push_back({label, original, adjusted, taxPercent, taxFactor, txId});
235 for (
const auto& row : result.
table) {
242bool drawTableImage(cv::Mat& image,
const std::vector<std::vector<std::string>>& rows)
244 if (rows.empty())
return false;
246 int y = scaled(image, 150);
247 const int lineHeight = scaled(image, 30);
248 const int maxRows = 16;
249 for (
size_t r = 0; r < rows.size() &&
static_cast<int>(r) < maxRows; ++r) {
250 std::ostringstream line;
251 for (
size_t c = 0; c < rows[r].size(); ++c) {
252 if (c > 0) line <<
" | ";
257 cv::Point(scaled(image, 50), y),
259 r == 0 ? scaledFont(image, 0.68) : scaledFont(image, 0.55),
260 r == 0 ? cv::Scalar(30, 30, 30, 255) : cv::Scalar(70, 70, 70, 255),
261 r == 0 ? scaled(image, 2) : std::max(1, scaled(image, 1)),
275 std::vector<Slice> slices;
276 for (
const auto& row : result.
table) {
277 if (row.empty())
continue;
278 const std::string label = row[0];
279 const double value = row.size() > 1 ? std::fabs(parseNumber(row[1])) : 0.0;
280 if (value > 0.0) slices.push_back({label, value});
282 if (slices.empty())
return false;
285 for (
const auto& slice : slices) total += slice.value;
286 if (total <= 0.0)
return false;
288 const int width = image.cols;
289 const int height = image.rows;
290 const int margin = scaled(image, 24);
291 const int legendWidth = std::max(320, width / 3);
292 const int pieAreaWidth = width - legendWidth - (margin * 3);
293 const int radiusPx = std::max(scaled(image, 120), std::min(pieAreaWidth / 2, height / 2 - margin));
294 const cv::Point center(margin + radiusPx, margin + radiusPx);
295 const cv::Size radius(radiusPx, radiusPx);
297 double startAngle = -90.0;
298 for (
size_t i = 0; i < slices.size(); ++i) {
299 const double angle = 360.0 * (slices[i].value / total);
300 const cv::Scalar color = colorForIndex(i);
301 cv::ellipse(image, center, radius, 0.0, startAngle, startAngle + angle, color, cv::FILLED, cv::LINE_AA);
305 const int legendX = margin + (radiusPx * 2) + margin;
306 int legendY = margin + scaled(image, 20);
307 const int maxLegendRows = std::max(1, (height - margin) / scaled(image, 30));
308 for (
size_t i = 0; i < slices.size() &&
static_cast<int>(i) < maxLegendRows; ++i) {
309 const cv::Scalar color = colorForIndex(i);
310 cv::rectangle(image, cv::Point(legendX, legendY - scaled(image, 16)), cv::Point(legendX + scaled(image, 20), legendY + scaled(image, 4)), color, cv::FILLED, cv::LINE_AA);
311 std::ostringstream label;
312 label << slices[i].label <<
" " << std::fixed << std::setprecision(2) << slices[i].value
313 <<
" " << std::setprecision(1) << (slices[i].value * 100.0 / total) <<
"%";
314 cv::putText(image, label.str(), cv::Point(legendX + scaled(image, 30), legendY), chartFontFace(), scaledFont(image, 0.55), cv::Scalar(50, 50, 50, 255), std::max(1, scaled(image, 1)), cv::LINE_AA);
315 legendY += scaled(image, 30);
326 std::unordered_map<std::string, double> byContract;
327 std::unordered_map<std::string, double> byProperty;
330 std::vector<Bucket> buckets;
331 std::vector<std::string> categories;
332 std::vector<std::string> propertyCategories;
333 for (
const auto& row : result.
table) {
334 if (row.empty())
continue;
336 std::string month = row[0];
338 if (row.size() > 1) {
339 nlohmann::json summary;
340 if (tryParseJsonObject(row[1], summary)) {
349 if (summary.contains(
"byContract") && summary[
"byContract"].is_object()) {
350 for (
const auto& item : summary[
"byContract"].items()) {
351 const std::string key = item.key();
352 if (std::find(categories.begin(), categories.end(), key) == categories.end()) {
353 categories.push_back(key);
357 if (summary.contains(
"byProperty") && summary[
"byProperty"].is_object()) {
358 for (
const auto& item : summary[
"byProperty"].items()) {
359 const std::string key = item.key();
360 if (std::find(propertyCategories.begin(), propertyCategories.end(), key) == propertyCategories.end()) {
361 propertyCategories.push_back(key);
366 total = std::fabs(parseNumber(row[1]));
371 bucket.label = month;
372 bucket.value = total;
373 if (row.size() > 1) {
374 nlohmann::json summary;
375 if (tryParseJsonObject(row[1], summary) && summary.contains(
"byContract") && summary[
"byContract"].is_object()) {
376 for (
const auto& item : summary[
"byContract"].items()) {
377 if (item.value().is_number()) {
378 bucket.byContract[item.key()] = std::fabs(item.value().get<
double>());
382 if (tryParseJsonObject(row[1], summary) && summary.contains(
"byProperty") && summary[
"byProperty"].is_object()) {
383 for (
const auto& item : summary[
"byProperty"].items()) {
384 if (item.value().is_number()) {
385 bucket.byProperty[item.key()] = std::fabs(item.value().get<
double>());
390 if (bucket.value <= 0.0 && !bucket.byContract.empty()) {
391 for (
const auto& [_, value] : bucket.byContract) {
392 bucket.value += value;
395 buckets.push_back(std::move(bucket));
397 if (buckets.empty())
return false;
398 if (categories.empty()) categories.push_back(
"Total");
400 const int width = image.cols;
401 const int height = image.rows;
402 const int margin = scaled(image, 24);
403 std::size_t longestPropertyLabel = 0;
404 for (
const auto& property : propertyCategories) {
405 longestPropertyLabel = std::max(longestPropertyLabel, property.size());
407 const int legendTop = height - scaled(image, 132);
408 const int labelBandHeight = propertyCategories.size() > 1
409 ? std::min(scaled(image, 360), scaled(image, 42) +
static_cast<int>(longestPropertyLabel) * scaled(image, 10))
411 const int left = margin;
412 const int right = width - margin;
413 const int top = margin;
414 const int bottom = legendTop - labelBandHeight;
416 double maxValue = 0.0;
417 for (
const auto& bucket : buckets) maxValue = std::max(maxValue, bucket.value);
418 if (maxValue <= 0.0) maxValue = 1.0;
420 const int count =
static_cast<int>(buckets.size());
421 const int slotWidth = std::max(scaled(image, 20), (right - left - scaled(image, 20)) / std::max(1, count));
422 const int barWidth = std::max(scaled(image, 10), slotWidth - scaled(image, 34));
423 const cv::Scalar barColor = colorForKey(
"Total");
424 const bool splitByProperty = propertyCategories.size() > 1;
425 for (
int i = 0; i < count; ++i) {
426 const auto& bucket = buckets[
static_cast<size_t>(i)];
427 const int x1 = left + (i * slotWidth) + ((slotWidth - barWidth) / 2);
428 if (splitByProperty && !bucket.byProperty.empty()) {
429 const int gap = scaled(image, 5);
430 const int propertyCount =
static_cast<int>(propertyCategories.size());
431 const int propertyBarWidth = std::max(scaled(image, 5), (barWidth - (gap * std::max(0, propertyCount - 1))) / std::max(1, propertyCount));
432 double contractTotal = 0.0;
433 for (
const auto& [_, value] : bucket.byContract) contractTotal += value;
434 if (contractTotal <= 0.0) contractTotal = bucket.value;
436 for (
int pi = 0; pi < propertyCount; ++pi) {
437 const auto&
property = propertyCategories[
static_cast<size_t>(pi)];
438 const auto propertyIt = bucket.byProperty.find(property);
439 const double propertyValue = propertyIt == bucket.byProperty.end() ? 0.0 : propertyIt->second;
440 const int px1 = x1 + (pi * (propertyBarWidth + gap));
441 const int px2 = px1 + propertyBarWidth;
442 int baseline = bottom;
444 if (bucket.byContract.empty()) {
445 const int barHeight =
static_cast<int>(((bottom - top - 10) * propertyValue) / maxValue);
446 cv::rectangle(image, cv::Point(px1, bottom - barHeight), cv::Point(px2, bottom - 1), barColor, cv::FILLED, cv::LINE_AA);
448 for (
const auto& category : categories) {
449 const auto contractIt = bucket.byContract.find(category);
450 if (contractIt == bucket.byContract.end())
continue;
451 const double segmentValue = contractTotal > 0.0 ? propertyValue * (contractIt->second / contractTotal) : 0.0;
452 const int segmentHeight =
static_cast<int>(((bottom - top - 10) * segmentValue) / maxValue);
453 const auto colorIndex =
static_cast<std::size_t
>(std::distance(categories.begin(), std::find(categories.begin(), categories.end(), category)));
454 cv::rectangle(image, cv::Point(px1, baseline - segmentHeight), cv::Point(px2, baseline - 1), colorForIndex(colorIndex), cv::FILLED, cv::LINE_AA);
455 baseline -= segmentHeight;
459 drawRotatedText(image,
461 cv::Point(px1 + std::max(0, (propertyBarWidth - scaled(image, 14)) / 2),
462 bottom + scaled(image, 26)),
463 scaledFont(image, 0.36),
464 cv::Scalar(70, 70, 70, 255),
465 std::max(1, scaled(image, 1)));
467 }
else if (bucket.byContract.empty()) {
468 const int barHeight =
static_cast<int>(((bottom - top - 10) * bucket.value) / maxValue);
469 cv::rectangle(image, cv::Point(x1, bottom - barHeight), cv::Point(x1 + barWidth, bottom - 1), barColor, cv::FILLED, cv::LINE_AA);
471 int baseline = bottom;
472 for (
const auto& category : categories) {
473 const auto it = bucket.byContract.find(category);
474 if (it == bucket.byContract.end())
continue;
475 const int segmentHeight =
static_cast<int>(((bottom - top - 10) * it->second) / maxValue);
476 const auto colorIndex =
static_cast<std::size_t
>(std::distance(categories.begin(), std::find(categories.begin(), categories.end(), category)));
477 cv::rectangle(image, cv::Point(x1, baseline - segmentHeight), cv::Point(x1 + barWidth, baseline - 1), colorForIndex(colorIndex), cv::FILLED, cv::LINE_AA);
478 baseline -= segmentHeight;
482 if (i % std::max(1, count / 12) == 0) {
483 cv::putText(image, bucket.label, cv::Point(x1, bottom + scaled(image, 18)), chartFontFace(), scaledFont(image, 0.4), cv::Scalar(70, 70, 70, 255), std::max(1, scaled(image, 1)), cv::LINE_AA);
487 int legendX = margin;
488 int legendY = legendTop + scaled(image, 26);
489 const int legendRight = width - margin;
490 for (
size_t i = 0; i < categories.size(); ++i) {
491 const auto& category = categories[i];
492 const int textWidth = std::max(scaled(image, 80),
static_cast<int>(category.size()) * scaled(image, 9));
493 const int itemWidth = textWidth + scaled(image, 48);
494 if (legendX + itemWidth > legendRight) {
496 legendY += scaled(image, 30);
498 cv::rectangle(image, cv::Point(legendX, legendY - scaled(image, 16)), cv::Point(legendX + scaled(image, 20), legendY + scaled(image, 4)), colorForIndex(i), cv::FILLED, cv::LINE_AA);
499 cv::putText(image, category, cv::Point(legendX + scaled(image, 30), legendY), chartFontFace(), scaledFont(image, 0.55), cv::Scalar(50, 50, 50, 255), std::max(1, scaled(image, 1)), cv::LINE_AA);
500 legendX += itemWidth;
506bool writeImageFromResult(
const std::filesystem::path& outputPath,
509 constexpr int width = 2560;
510 constexpr int height = 1440;
511 const bool asPng = outputPath.has_extension()
512 && outputPath.extension().string() ==
".png";
515 if (asPng) image = cv::Mat(height, width, CV_8UC4, cv::Scalar(0, 0, 0, 0));
516 else image = cv::Mat(height, width, CV_8UC3, cv::Scalar(250, 250, 250));
520 ok = drawPieChartImage(image, result);
522 ok = drawHistogramImage(image, result);
524 ok = drawTableImage(image, normalizedRowsForImage(result));
527 if (!ok)
return false;
529 return cv::imwrite(outputPath.string(), image);
538void reportImageFailure(core::ports::diagnostics::IErrorReporter* reporter,
540 const std::filesystem::path& outputPath)
542 if (!reporter)
return;
544 core::errors::ErrorEvent event;
546 event.origin =
"infra::analysis_rendering::OpenCvAnalysisRendererAdapter::renderToImage";
547 event.message = std::move(message);
549 event.context = {{
"path", outputPath.string()}};
556 std::shared_ptr<core::ports::diagnostics::IErrorReporter> errorReporter)
557 : errorReporter_(std::move(errorReporter))
562 const std::string& title,
567 const bool ok = writeImageFromResult(outputPath, result);
569 reportImageFailure(errorReporter_.get(),
"failed to render analysis image", outputPath);
575 "infra::analysis_rendering::OpenCvAnalysisRendererAdapter::renderToImage",
576 std::current_exception());
Defines stable analysis type, configuration, filter, and result keys.
Provides shared helpers for reporting structured errors and exceptions.
Declares the OpenCV-based analysis image renderer adapter.
bool renderToImage(const std::filesystem::path &outputPath, const std::string &title, const core::ports::analysis::AnalysisResult &result) const override
Renders one analysis artifact to an image file.
OpenCvAnalysisRendererAdapter(std::shared_ptr< core::ports::diagnostics::IErrorReporter > errorReporter)
Defines shared structured error codes.
constexpr std::string_view kHistogram
constexpr std::string_view kPie
constexpr std::string_view kAmountAdjusted
constexpr std::string_view kMonth
constexpr std::string_view kTransactionId
constexpr std::string_view kTaxFactor
constexpr std::string_view kTaxPercent
constexpr std::string_view kTotal
constexpr std::string_view kAmountOriginal
constexpr std::string_view kTypeCalculation
constexpr const char * GenericError
Fallback code for generic structured errors.
void reportException(core::ports::diagnostics::IErrorReporter *reporter, ErrorSeverity severity, const char *origin, std::exception_ptr exception)
Report an exception through the reporter's default exception handling.
void report(core::ports::diagnostics::IErrorReporter *reporter, const ErrorEvent &event)
Report an already constructed error event when a reporter exists.
std::vector< std::vector< std::string > > table