FOSSredder 0.5.0
Loading...
Searching...
No Matches
OpenCvAnalysisRendererAdapter.cpp
Go to the documentation of this file.
1
5
7
11
12#include <algorithm>
13#include <cmath>
14#include <cstdint>
15#include <exception>
16#include <iomanip>
17#include <sstream>
18#include <unordered_map>
19#include <utility>
20#include <nlohmann/json.hpp>
21
22#include <opencv2/core.hpp>
23#include <opencv2/imgcodecs.hpp>
24#include <opencv2/imgproc.hpp>
25
26namespace {
27// Reuse the existing rendering logic as-is.
28double parseNumber(const std::string& text)
29{
30 try {
31 return std::stod(text);
32 } catch (...) {
33 return 0.0;
34 }
35}
36
37bool tryParseJsonObject(const std::string& text, nlohmann::json& out)
38{
39 try {
40 out = nlohmann::json::parse(text);
41 return out.is_object();
42 } catch (...) {
43 return false;
44 }
45}
46
47std::int32_t qmlHashString(const std::string& value)
48{
49 std::int32_t hash = 0;
50 for (const unsigned char ch : value) {
51 hash = static_cast<std::int32_t>((hash << 5) - hash + ch);
52 }
53 return hash < 0 ? -hash : hash;
54}
55
56const std::vector<cv::Scalar>& qmlChartPalette()
57{
58 static const std::vector<cv::Scalar> colors = {
59 cv::Scalar(199, 211, 141, 255), // #8dd3c7
60 cv::Scalar(179, 255, 255, 255), // #ffffb3
61 cv::Scalar(218, 186, 190, 255), // #bebada
62 cv::Scalar(114, 128, 251, 255), // #fb8072
63 cv::Scalar(211, 177, 128, 255), // #80b1d3
64 cv::Scalar(98, 180, 253, 255), // #fdb462
65 cv::Scalar(105, 222, 179, 255), // #b3de69
66 cv::Scalar(229, 205, 252, 255), // #fccde5
67 cv::Scalar(217, 217, 217, 255), // #d9d9d9
68 cv::Scalar(189, 128, 188, 255), // #bc80bd
69 cv::Scalar(197, 235, 204, 255), // #ccebc5
70 cv::Scalar(111, 237, 255, 255), // #ffed6f
71 };
72 return colors;
73}
74
75cv::Scalar colorForIndex(std::size_t index)
76{
77 const auto& colors = qmlChartPalette();
78 if (colors.empty()) return cv::Scalar(119, 170, 221, 255);
79 return colors[index % colors.size()];
80}
81
82cv::Scalar colorForKey(const std::string& key)
83{
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);
88}
89
90double renderScale(const cv::Mat& image)
91{
92 return std::max(1.0, static_cast<double>(image.cols) / 1280.0);
93}
94
95int scaled(const cv::Mat& image, int value)
96{
97 return static_cast<int>(std::round(value * renderScale(image)));
98}
99
100double scaledFont(const cv::Mat& image, double value)
101{
102 return value * renderScale(image);
103}
104
105int chartFontFace()
106{
107 return cv::FONT_HERSHEY_SIMPLEX;
108}
109
110void drawRotatedText(cv::Mat& image,
111 const std::string& text,
112 const cv::Point& topLeft,
113 double fontScale,
114 const cv::Scalar& color,
115 int thickness)
116{
117 if (text.empty()) return;
118
119 int baseline = 0;
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,
123 text,
124 cv::Point(4, textSize.height + 2),
125 chartFontFace(),
126 fontScale,
127 color,
128 thickness,
129 cv::LINE_AA);
130
131 cv::Mat rotated;
132 cv::rotate(textImage, rotated, cv::ROTATE_90_CLOCKWISE);
133
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;
140
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;
145 } else {
146 image.at<cv::Vec3b>(targetY, targetX) = cv::Vec3b(source[0], source[1], source[2]);
147 }
148 }
149 }
150}
151
152std::vector<std::vector<std::string>> normalizedRowsForImage(const core::ports::analysis::AnalysisResult& result)
153{
161
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]});
169 }
170 return rows;
171 }
172
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>();
184 }
185 if (summary.contains(kTotal) && summary[kTotal].is_number()) {
186 totalValue = std::to_string(summary[kTotal].get<double>());
187 }
188 } else {
189 totalValue = row[1];
190 }
191 }
192 rows.push_back({month, totalValue});
193 }
194 return rows;
195 }
196
198 rows.push_back({"Label", "OriginalAmount", "AdjustedAmount", "TaxPercent", "TaxFactor", "TransactionId"});
199 for (const auto& row : result.table) {
200 if (row.empty()) continue;
201
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 = "";
208
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>());
214 }
215 if (summary.contains(kAmountAdjusted) && summary[kAmountAdjusted].is_number()) {
216 adjusted = std::to_string(summary[kAmountAdjusted].get<double>());
217 }
218 if (summary.contains(kTaxPercent) && summary[kTaxPercent].is_number()) {
219 taxPercent = std::to_string(summary[kTaxPercent].get<double>());
220 }
221 if (summary.contains(kTaxFactor) && summary[kTaxFactor].is_number()) {
222 taxFactor = std::to_string(summary[kTaxFactor].get<double>());
223 }
224 if (summary.contains(kTransactionId) && summary[kTransactionId].is_string()) {
225 txId = summary[kTransactionId].get<std::string>();
226 }
227 }
228 }
229
230 rows.push_back({label, original, adjusted, taxPercent, taxFactor, txId});
231 }
232 return rows;
233 }
234
235 for (const auto& row : result.table) {
236 rows.push_back(row);
237 }
238
239 return rows;
240}
241
242bool drawTableImage(cv::Mat& image, const std::vector<std::vector<std::string>>& rows)
243{
244 if (rows.empty()) return false;
245
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 << " | ";
253 line << rows[r][c];
254 }
255 cv::putText(image,
256 line.str(),
257 cv::Point(scaled(image, 50), y),
258 chartFontFace(),
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)),
262 cv::LINE_AA);
263 y += lineHeight;
264 }
265 return true;
266}
267
268bool drawPieChartImage(cv::Mat& image, const core::ports::analysis::AnalysisResult& result)
269{
270 struct Slice {
271 std::string label;
272 double value = 0.0;
273 };
274
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});
281 }
282 if (slices.empty()) return false;
283
284 double total = 0.0;
285 for (const auto& slice : slices) total += slice.value;
286 if (total <= 0.0) return false;
287
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);
296
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);
302 startAngle += angle;
303 }
304
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);
316 }
317
318 return true;
319}
320
321bool drawHistogramImage(cv::Mat& image, const core::ports::analysis::AnalysisResult& result)
322{
323 struct Bucket {
324 std::string label;
325 double value = 0.0;
326 std::unordered_map<std::string, double> byContract;
327 std::unordered_map<std::string, double> byProperty;
328 };
329
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;
335
336 std::string month = row[0];
337 double total = 0.0;
338 if (row.size() > 1) {
339 nlohmann::json summary;
340 if (tryParseJsonObject(row[1], summary)) {
343 month = summary[core::application::analysis::keys::resultFields::kMonth].get<std::string>();
344 }
347 total = std::fabs(summary[core::application::analysis::keys::resultFields::kTotal].get<double>());
348 }
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);
354 }
355 }
356 }
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);
362 }
363 }
364 }
365 } else {
366 total = std::fabs(parseNumber(row[1]));
367 }
368 }
369
370 Bucket bucket;
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>());
379 }
380 }
381 }
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>());
386 }
387 }
388 }
389 }
390 if (bucket.value <= 0.0 && !bucket.byContract.empty()) {
391 for (const auto& [_, value] : bucket.byContract) {
392 bucket.value += value;
393 }
394 }
395 buckets.push_back(std::move(bucket));
396 }
397 if (buckets.empty()) return false;
398 if (categories.empty()) categories.push_back("Total");
399
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());
406 }
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))
410 : scaled(image, 46);
411 const int left = margin;
412 const int right = width - margin;
413 const int top = margin;
414 const int bottom = legendTop - labelBandHeight;
415
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;
419
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;
435
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;
443
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);
447 } else {
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;
456 }
457 }
458
459 drawRotatedText(image,
460 property,
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)));
466 }
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);
470 } else {
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;
479 }
480 }
481
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);
484 }
485 }
486
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) {
495 legendX = margin;
496 legendY += scaled(image, 30);
497 }
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;
501 }
502
503 return true;
504}
505
506bool writeImageFromResult(const std::filesystem::path& outputPath,
508{
509 constexpr int width = 2560;
510 constexpr int height = 1440;
511 const bool asPng = outputPath.has_extension()
512 && outputPath.extension().string() == ".png";
513
514 cv::Mat image;
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));
517
518 bool ok = false;
520 ok = drawPieChartImage(image, result);
522 ok = drawHistogramImage(image, result);
523 } else {
524 ok = drawTableImage(image, normalizedRowsForImage(result));
525 }
526
527 if (!ok) return false;
528
529 return cv::imwrite(outputPath.string(), image);
530}
531
532} // namespace
533
535
536namespace {
537
538void reportImageFailure(core::ports::diagnostics::IErrorReporter* reporter,
539 std::string message,
540 const std::filesystem::path& outputPath)
541{
542 if (!reporter) return;
543
544 core::errors::ErrorEvent event;
545 event.severity = core::errors::ErrorSeverity::Error;
546 event.origin = "infra::analysis_rendering::OpenCvAnalysisRendererAdapter::renderToImage";
547 event.message = std::move(message);
549 event.context = {{"path", outputPath.string()}};
550 core::errors::report(reporter, event);
551}
552
553} // namespace
554
556 std::shared_ptr<core::ports::diagnostics::IErrorReporter> errorReporter)
557 : errorReporter_(std::move(errorReporter))
558{
559}
560
561bool OpenCvAnalysisRendererAdapter::renderToImage(const std::filesystem::path& outputPath,
562 const std::string& title,
563 const core::ports::analysis::AnalysisResult& result) const
564{
565 (void)title;
566 try {
567 const bool ok = writeImageFromResult(outputPath, result);
568 if (!ok) {
569 reportImageFailure(errorReporter_.get(), "failed to render analysis image", outputPath);
570 }
571 return ok;
572 } catch (...) {
573 core::errors::reportException(errorReporter_.get(),
575 "infra::analysis_rendering::OpenCvAnalysisRendererAdapter::renderToImage",
576 std::current_exception());
577 return false;
578 }
579}
580
581} // namespace infra::analysis_rendering
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 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