Source code for msqms.reports.report

# -*- coding: utf-8 -*-
"""Generate MEG Pipeline HTML Report"""

import json
import jinja2
import os.path as op
from box import Box
import pandas as pd
import numpy as np

from tqdm.auto import tqdm
from typing import Union, List, Dict
from pathlib import Path
from jinja2 import Environment, PackageLoader
from mne.io import read_raw

from msqms.qc import get_header_info
from msqms.utils.logging import clogger
from msqms.utils import get_configure, check_if_directory
from msqms.qc.msqm import MSQM
from msqms.constants import DATA_TYPE
from msqms.qc.visual_inspection import VisualInspection
from msqms.constants import METRICS_COLUMNS, METRICS_REPORT_MAPPING, METRICS_MAPPING


[docs] def gen_quality_report(megfiles: [Union[str, Path]], outdir: Union[str, Path], report_fname: str = "", data_type: DATA_TYPE = "", ftype: str = 'html'): """Generate HTML/JSON Report for a set of MEG Raw data. Parameters ---------- megfiles : [Union[str, Path]] Paths to the MEG files for which the quality reports will be generated. outdir : Union[str, Path] The directory where the generated report will be saved. report_fname: str The name of the generated report file. Default is an empty string. data_type: DATA_TYPE, optional The type of data. Either 'opm' or 'squid'. Default is an empty string. ftype : str The format of the report file to be generated. Either 'html' or 'json'. Default is 'html'. Returns ------- dict A dictionary containing the quality assessment data: { "msqm_score": msqm_score, "details": details, "category_scores": category_scores } """ # validate meg files. if isinstance(megfiles, str): megfiles = [megfiles] # validate outdir check_if_directory(outdir) for fmeg in megfiles: clogger.info(f"Generating report for {fmeg}") # check meg file if not op.exists(fmeg): clogger.error(f"{fmeg} is not exists. Please check the path of file.") raw = read_raw(fmeg, verbose=False, preload=True) if raw.info["sfreq"] != 1000: raw = raw.resample(1000) # compute the msqm score and obtain the reference values & hints[↑↓✔] config_dict = get_configure(data_type=data_type) high_pass = config_dict["data_type"]["high_pass_freq"] low_pass = config_dict["data_type"]["low_pass_freq"] notch_freq = config_dict["data_type"]["notch_filter_freq"] clogger.info(f"Minimal preprocessing: high-pass:{high_pass},low-pass:{low_pass} and notch_filter:{notch_freq}") raw_filter = raw.copy().filter(l_freq=high_pass, h_freq=low_pass, n_jobs=-1, verbose=True).notch_filter( notch_freq, n_jobs=-1, verbose=True) msqm = MSQM(raw_filter, origin_raw=raw, data_type=data_type, verbose=10, n_jobs=4) clogger.info(f"compute the msqm score...") msqm_dict = msqm.compute_msqm_score() msqm_score = msqm_dict['msqm_score'] details = msqm_dict['details'] category_scores = msqm_dict['category_scores'] fmeg_fname = Path(raw.filenames[0]).stem vis = VisualInspection(raw=raw_filter, output_fpath=op.join(outdir, f'{fmeg_fname}.imgs')) meg_data = raw_filter.get_data('mag') nan_mask = msqm.nan_mask bad_chan_mask = msqm.bad_chan_mask bad_seg_mask = msqm.bad_seg_mask flat_mask = msqm.flat_mask bad_chan_names = msqm.bad_chan_names zero_mask = msqm.zero_mask vis.visual_psd() vis.visualize_heatmap(data=meg_data, bad_mask=zero_mask, filename="Heatmap_zerovalue.html", label='ZeroValue') vis.visualize_heatmap(data=meg_data, bad_mask=nan_mask, filename="Heatmap_NaN.html", label='NaN') vis.visualize_heatmap(data=meg_data, bad_mask=bad_chan_mask, filename="Heatmap_bad_channels.html", label='BadChannel') vis.visualize_heatmap(data=meg_data, bad_mask=bad_seg_mask, filename="Heatmap_bad_segments.html", label='BadSegments') vis.visualize_heatmap(data=meg_data, bad_mask=flat_mask, filename="Heatmap_flat_channels.html", label='Flat') vis.visual_bad_channel_topomap(bad_channels=bad_chan_names, filename="Bad_channels_distribution.png", show_names=True) # for debug # msqm_score = 0.88 # bad_chan_names = [''] # details = {'max_ptp': {'quality_score': 1, 'metric_score': 4.210898388610023e-12, 'lower_bound': -2.3602284324990543e-07, 'upper_bound': 2.6493921972267915e-07, 'hint': '✔'}, 'S': {'quality_score': 0.636571456992661, 'metric_score': 1.9430031627543123, 'lower_bound': 2.623627306847128, 'upper_bound': 4.4964140133086286, 'hint': '↓'}, 'C': {'quality_score': 0.8390398792210456, 'metric_score': 10.48615742957991, 'lower_bound': 11.369409015417698, 'upper_bound': 16.85680292120852, 'hint': '↓'}, 'I': {'quality_score': 0.6980531016926521, 'metric_score': 20.329387368006188, 'lower_bound': 31.923511813784586, 'upper_bound': 70.32140399539887, 'hint': '↓'}, 'L': {'quality_score': 1, 'metric_score': 1.7146758299291877e-05, 'lower_bound': -0.0047155314087559555, 'upper_bound': 0.007008033308854388, 'hint': '✔'}, 'mmr': {'quality_score': 1, 'metric_score': 9.688842656372957e-12, 'lower_bound': -7.945065272613537e-07, 'upper_bound': 9.563664839987926e-07, 'hint': '✔'}, 'max_field_change': {'quality_score': 1, 'metric_score': 8.898472110522491e-13, 'lower_bound': -1.3707800756858704e-07, 'upper_bound': 1.6579044812575712e-07, 'hint': '✔'}, 'mean_field_change': {'quality_score': 1, 'metric_score': 2.1588871514957103e-14, 'lower_bound': -1.1223198749742977e-09, 'upper_bound': 1.2619573968340738e-09, 'hint': '✔'}, 'std_field_change': {'quality_score': 1, 'metric_score': 1.9845503045623648e-14, 'lower_bound': -1.984192939785529e-09, 'upper_bound': 2.3121304240874602e-09, 'hint': '✔'}, 'rms': {'quality_score': 1, 'metric_score': 9.233427346002339e-13, 'lower_bound': -5.047210123157186e-08, 'upper_bound': 6.175431918543353e-08, 'hint': '✔'}, 'arv': {'quality_score': 1, 'metric_score': 4.8084900650295e-13, 'lower_bound': -1.58680313745648e-08, 'upper_bound': 1.868690817597984e-08, 'hint': '✔'}, 'mean': {'quality_score': 1, 'metric_score': 1.244763710138051e-16, 'lower_bound': -4.171571251074098e-09, 'upper_bound': 5.321189796997719e-09, 'hint': '✔'}, 'variance': {'quality_score': 1, 'metric_score': 8.537696635943241e-25, 'lower_bound': -1.4282025550921224e-13, 'upper_bound': 1.64282023562554e-13, 'hint': '✔'}, 'std_values': {'quality_score': 1, 'metric_score': 9.233406553783618e-13, 'lower_bound': -5.016812603103612e-08, 'upper_bound': 6.136265224339484e-08, 'hint': '✔'}, 'max_values': {'quality_score': 1, 'metric_score': 4.864015795668343e-12, 'lower_bound': -6.063888006058493e-07, 'upper_bound': 7.338068429236114e-07, 'hint': '✔'}, 'min_values': {'quality_score': 1, 'metric_score': -4.824826860704613e-12, 'lower_bound': -2.3952317312808677e-07, 'upper_bound': 2.0508125870840996e-07, 'hint': '✔'}, 'median_values': {'quality_score': 1, 'metric_score': -1.4347550864406708e-15, 'lower_bound': -2.405120604068551e-10, 'upper_bound': 2.557771831620386e-10, 'hint': '✔'}, 'hjorth_mobility': {'quality_score': 0, 'metric_score': 0.03166494337708053, 'lower_bound': 0.01013033613615232, 'upper_bound': 0.02416418566031306, 'hint': '✘'}, 'hjorth_complexity': {'quality_score': 0, 'metric_score': 6.350346580480347, 'lower_bound': 22.890677614955045, 'upper_bound': 36.8436539670498, 'hint': '✘'}, 'num_of_zero_crossings': {'quality_score': 0, 'metric_score': 0.021937002852193698, 'lower_bound': 0.003070693032658337, 'upper_bound': 0.021341148222957174, 'hint': '✘'}, 'DFA': {'quality_score': 0.3391594254634005, 'metric_score': 1.2738102058565535, 'lower_bound': 1.3511475941139013, 'upper_bound': 1.468176407472674, 'hint': '↓'}, 'max_mean_offset': {'quality_score': 0.05239873874132572, 'metric_score': 5.3753023025987705e-15, 'lower_bound': 2.349895905397193e-08, 'upper_bound': 4.8297315324051794e-08, 'hint': '↓'}, 'mean_offset': {'quality_score': 0, 'metric_score': 1.5658019607978766e-15, 'lower_bound': 7.625835425053466e-10, 'upper_bound': 1.5114204288677438e-09, 'hint': '✘'}, 'Zero_ratio': {'quality_score': 0, 'metric_score': 2.991443019976258e-05, 'lower_bound': 1.0442435968838144e-06, 'upper_bound': 2.5057697378509674e-06, 'hint': '✘'}, 'std_mean_offset': {'quality_score': 0.0365161833706501, 'metric_score': 1.1682583555008518e-15, 'lower_bound': 2.882874800947118e-09, 'upper_bound': 5.875009783322018e-09, 'hint': '↓'}, 'max_median_offset': {'quality_score': 1, 'metric_score': 3.830726374525228e-14, 'lower_bound': -1.4891710347261785e-09, 'upper_bound': 3.0224937251558155e-09, 'hint': '✔'}, 'median_offset': {'quality_score': 1, 'metric_score': 1.410822042046943e-14, 'lower_bound': -1.8422225583199954e-11, 'upper_bound': 3.801576774448462e-11, 'hint': '✔'}, 'std_median_offset': {'quality_score': 1, 'metric_score': 9.191078203473446e-15, 'lower_bound': -1.6531834976119388e-10, 'upper_bound': 3.361236437270235e-10, 'hint': '✔'}, 'p1': {'quality_score': 1, 'metric_score': 2.4832067047934644e-16, 'lower_bound': -9.591375683090533e-12, 'upper_bound': 1.1301554434520452e-11, 'hint': '✔'}, 'p2': {'quality_score': 1, 'metric_score': 1.667272495104182e-15, 'lower_bound': -8.395036169580603e-11, 'upper_bound': 1.0301607143740373e-10, 'hint': '✔'}, 'p3': {'quality_score': 0, 'metric_score': 13.051892184840463, 'lower_bound': 55.77079859170577, 'upper_bound': 94.04879306196314, 'hint': '✘'}, 'p4': {'quality_score': 0.8292491605732685, 'metric_score': 217.68969334431858, 'lower_bound': 2305.05207596116, 'upper_bound': 14529.661861920777, 'hint': '↓'}, 'p5': {'quality_score': 0, 'metric_score': 11.53772276948559, 'lower_bound': 22.547078527942592, 'upper_bound': 29.888380741483562, 'hint': '✘'}, 'p6': {'quality_score': 1, 'metric_score': 2.7658187426768086e-07, 'lower_bound': -1.5768496151932156e-05, 'upper_bound': 2.530029412795343e-05, 'hint': '✔'}, 'p7': {'quality_score': 0, 'metric_score': 21.064549884092635, 'lower_bound': 47.136415086333095, 'upper_bound': 61.943142022865636, 'hint': '✘'}, 'p8': {'quality_score': 0, 'metric_score': 103.55648198443087, 'lower_bound': 208.34594301408768, 'upper_bound': 263.66065100566533, 'hint': '✘'}, 'p9': {'quality_score': 0.9611573509700524, 'metric_score': 0.2087925817155825, 'lower_bound': 0.21063572116970164, 'upper_bound': 0.258087156644612, 'hint': '↓'}, 'p10': {'quality_score': 1, 'metric_score': 2.4007934211805027e-08, 'lower_bound': -7.464103895573939e-07, 'upper_bound': 1.2350452365259103e-06, 'hint': '✔'}, 'p11': {'quality_score': 0, 'metric_score': 246503107.92904365, 'lower_bound': 69291243.81429276, 'upper_bound': 164658062.69994727, 'hint': '✘'}, 'p12': {'quality_score': 0, 'metric_score': 1.5377136730303622e+17, 'lower_bound': 7806190304380896.0, 'upper_bound': 7.057538000383507e+16, 'hint': '✘'}, 'p13': {'quality_score': 1, 'metric_score': 5.408849834352705e-12, 'lower_bound': -8.739443023589972e-09, 'upper_bound': 1.068327565061985e-08, 'hint': '✔'}, 'permutation_entropy': {'quality_score': 0, 'metric_score': 0.5166734544214481, 'lower_bound': 0.6641767444467576, 'upper_bound': 0.7346060768086465, 'hint': '✘'}, 'spectral_entropy': {'quality_score': 0.4380497715833509, 'metric_score': 0.27676763057922776, 'lower_bound': 0.3449191998212739, 'upper_bound': 0.4661960777292516, 'hint': '↓'}, 'svd_entropy': {'quality_score': 0, 'metric_score': 0.1156067172245473, 'lower_bound': 0.05112847417811117, 'upper_bound': 0.09246998490193437, 'hint': '✘'}, 'approximate_entropy': {'quality_score': 0, 'metric_score': 0.09506681629683165, 'lower_bound': -0.002183724451159292, 'upper_bound': 0.057771169014295964, 'hint': '✘'}, 'sample_entropy': {'quality_score': 0, 'metric_score': 0.078396306543074, 'lower_bound': -0.0016331515242551733, 'upper_bound': 0.05194606203916407, 'hint': '✘'}, 'power_spectral_entropy': {'quality_score': 0.43804977158335046, 'metric_score': 1.34503876421472, 'lower_bound': 1.6762426057939293, 'upper_bound': 2.265625481413375, 'hint': '↓'}, 'Total_Energy': {'quality_score': 1, 'metric_score': 8.554415163699364e-20, 'lower_bound': -5.449294969381888e-08, 'upper_bound': 6.265338795968155e-08, 'hint': '✔'}, 'Total_Entropy': {'quality_score': 0, 'metric_score': 35.68322802369352, 'lower_bound': 17.650418174617535, 'upper_bound': 34.229796349908575, 'hint': '✘'}, 'Energy_Entropy_Ratio': {'quality_score': 1, 'metric_score': 2.4016681733929615e-21, 'lower_bound': -1.397143406697829e-09, 'upper_bound': 1.6339349602116215e-09, 'hint': '✔'}, 'PFD': {'quality_score': 0.6140837342674625, 'metric_score': 1.0017195951496918, 'lower_bound': 1.0030936843330922, 'upper_bound': 1.0066542732623827, 'hint': '↓'}, 'KFD': {'quality_score': 0, 'metric_score': 2.100654219989885, 'lower_bound': 1.456704486133142, 'upper_bound': 1.8462845606170841, 'hint': '✘'}, 'HFD': {'quality_score': 0, 'metric_score': 1.039029700811997, 'lower_bound': 1.237477492170722, 'upper_bound': 1.3493118670462148, 'hint': '✘'}, 'BadChanRatio': {'quality_score': 0, 'metric_score': 0.11538461538461539, 'lower_bound': 0.0, 'upper_bound': 0.04, 'hint': '✘'}, 'BadSegmentsRatio': {'quality_score': 1, 'metric_score': 0.0024937655860348684, 'lower_bound': 0.0, 'upper_bound': 0.0025, 'hint': '✔'}, 'NaN_ratio': {'quality_score': 1, 'metric_score': 0.0, 'lower_bound': 0.0, 'upper_bound': 0.0025, 'hint': '✔'}, 'Flat_chan_ratio': {'quality_score': 0, 'metric_score': 97.43589743589743, 'lower_bound': 0.0, 'upper_bound': 0.0025, 'hint': '✘'}} # category_scores = {"time_domain": 0.3, 'artifacts': 0.2, 'frequency_domain': 0.2, 'entropy': 0.2, # 'fractal': 0.2} info = get_header_info(raw_filter) # update bad channels info.basic_info.Bad_channels = bad_chan_names quality_ref = {"msqm_score": msqm_score, "details": details, "category_scores": category_scores} qreport = QualityReport(report_data=Box( {"Overview": info, "Quality_Ref": quality_ref}), minify_html=False) report_name = op.join(outdir, f"{report_fname}.{ftype}") if ftype == "json": qreport.to_json(report_name) else: qreport.to_html(report_name) return quality_ref
[docs] def gen_summary_quality_report(megfiles: List[Union[str, Path]], outdir: Union[str, Path], report_fname: str = "summary_report", data_type: DATA_TYPE = "", ftype: str = 'html'): """Generate a summary HTML report for multiple MEG files with quality scores distribution. Parameters ---------- megfiles : List[Union[str, Path]] List of paths to the MEG files for which the summary report will be generated. outdir : Union[str, Path] The directory where the generated report will be saved. report_fname: str The name of the generated summary report file. Default is "summary_report". data_type: DATA_TYPE, optional The type of data. Either 'opm' or 'squid'. Default is an empty string. ftype : str The format of the report file to be generated. Either 'html' or 'json'. Default is 'html'. Returns ------- dict A dictionary containing summary statistics for all files. """ # validate meg files if isinstance(megfiles, str): megfiles = [megfiles] if len(megfiles) == 0: clogger.error("No MEG files provided for summary report generation.") return None # validate outdir check_if_directory(outdir) clogger.info(f"Generating summary report for {len(megfiles)} MEG files") # Collect data from all files all_reports_data = [] summary_stats = { 'msqm_scores': [], 'category_scores': { 'time_domain': [], 'frequency_domain': [], 'entropy': [], 'fractal': [], 'artifacts': [] }, 'file_info': [] } # Generate individual reports and collect data for idx, fmeg in enumerate(tqdm(megfiles, desc="Processing files")): if not op.exists(fmeg): clogger.warning(f"{fmeg} does not exist. Skipping...") continue try: # Generate individual report fmeg_fname = Path(fmeg).stem individual_report_fname = f"{fmeg_fname}.report" quality_ref = gen_quality_report( [fmeg], outdir=outdir, report_fname=individual_report_fname, data_type=data_type, ftype=ftype ) if quality_ref: # Store report data # Use relative path for report_path so it works when viewing the summary report # Since both summary and individual reports are in the same directory, use just the filename report_data = { 'filename': fmeg_fname, 'filepath': str(fmeg), 'report_path': f"{individual_report_fname}.html", 'msqm_score': quality_ref['msqm_score'], 'category_scores': quality_ref['category_scores'], 'details': quality_ref['details'] } all_reports_data.append(report_data) # Collect statistics summary_stats['msqm_scores'].append(quality_ref['msqm_score']) for category in summary_stats['category_scores'].keys(): if category in quality_ref['category_scores']: summary_stats['category_scores'][category].append( quality_ref['category_scores'][category] ) summary_stats['file_info'].append({ 'filename': fmeg_fname, 'msqm_score': quality_ref['msqm_score'], 'category_scores': quality_ref['category_scores'] }) except Exception as e: clogger.error(f"Error processing {fmeg}: {str(e)}") continue if len(all_reports_data) == 0: clogger.error("No valid reports generated. Cannot create summary report.") return None # Calculate summary statistics summary_stats['msqm_scores'] = np.array(summary_stats['msqm_scores']) summary_stats['statistics'] = { 'total_files': len(all_reports_data), 'msqm_mean': float(np.mean(summary_stats['msqm_scores'])), 'msqm_std': float(np.std(summary_stats['msqm_scores'])), 'msqm_min': float(np.min(summary_stats['msqm_scores'])), 'msqm_max': float(np.max(summary_stats['msqm_scores'])), 'msqm_median': float(np.median(summary_stats['msqm_scores'])), 'category_stats': {} } # Calculate category statistics for category in summary_stats['category_scores'].keys(): if len(summary_stats['category_scores'][category]) > 0: cat_scores = np.array(summary_stats['category_scores'][category]) summary_stats['statistics']['category_stats'][category] = { 'mean': float(np.mean(cat_scores)), 'std': float(np.std(cat_scores)), 'min': float(np.min(cat_scores)), 'max': float(np.max(cat_scores)), 'median': float(np.median(cat_scores)) } # Generate summary report HTML summary_report = SummaryQualityReport( report_data=Box({ "reports": all_reports_data, "summary_stats": summary_stats }), minify_html=False ) report_name = op.join(outdir, f"{report_fname}.{ftype}") if ftype == "json": summary_report.to_json(report_name) else: summary_report.to_html(report_name) clogger.info(f"Summary report generated: {report_name}") return summary_stats
[docs] class QualityReport(object): """ Generate a quality report from MEG raw data. """ def __init__(self, report_data, minify_html, ): """ Parameters ---------- report_data : dict The report data to be included in the report. minify_html : bool Whether to minify the HTML report. """ self.report_data = report_data self.minify_html = minify_html
[docs] def to_json(self, out_json_path: Union[str, Path]) -> None: """ Write the report to a JSON file. Parameters ---------- out_json_path : str or Path The path where the JSON report will be saved. """ with tqdm(total=1, desc="Render JSON") as pbar: report_data = json.dumps(self.report_data, indent=4) pbar.update() self._to_file(report_data=report_data, output_file=out_json_path)
[docs] def to_html(self, out_html_path: Union[str, Path]) -> None: """ Write the report to an HTML file. Parameters ---------- out_html_path : str or Path The path where the HTML report will be saved. """ with tqdm(total=1, desc="Render Html") as pbar: html = HtmlReport(self.report_data).render_html() if self.minify_html: import minify_html html = minify_html.minify(html, minify_js=True, minify_css=True, ) pbar.update() self._to_file(report_data=html, output_file=out_html_path)
def _to_file(self, report_data: str, output_file: Union[str, Path]) -> None: """ Write the report data to a file. Parameters ---------- report_data : str The report data to be written to the file. output_file : str or Path The path to the file where the report will be saved. """ if not isinstance(output_file, Path): output_file = Path(str(output_file)) if output_file.suffix not in [".html", ".json"]: suffix = output_file.suffix output_file = output_file.with_suffix(".html") clogger.warning( f"Extension {suffix} not supported. We use .html instead." f"To remove this warning, please use .html or .json." ) with tqdm(total=1, desc="Export quality report to file") as pbar: output_file.write_text(report_data, encoding="utf-8") pbar.update() clogger.info(f"Export quality report path:{output_file}")
[docs] class HtmlReport(object): """ Generate an HTML report for MEG quality metrics. """ def __init__(self, report_data): """ Initialize the HTML report generator. Parameters ---------- report_data : dict The data to be included in the HTML report. """ # Init Jinja package_loader = PackageLoader(package_name="msqms", package_path="reports/templates") self.jinja2_env = Environment(loader=package_loader) self.report_data = report_data self.nav_title = "<strong>MEG Quality Report</strong>" self.info_title_name = "MEG Data Info" self.overview_title_name = "MEG Quality Overview" # MEG data info self.info_tabs = [("Overview", "overview"), ("Participant Info", "participantinfo"), ("MEG Info", "meginfo")] # basic info basic_info = self.report_data.Overview.basic_info meg_info = self.report_data.Overview.meg_info self.info_basic = { "Manufacturer": basic_info.Experimenter, "Duration": basic_info.Duration, "Frequency": basic_info.Sampling_frequency, "Highpass": basic_info.Highpass, "Lowpass": basic_info.Lowpass, "Data Size": basic_info.Data_size, "Bad Channels": basic_info.Bad_channels, "Measurement date": basic_info.Measurement_date, "Source filename": basic_info.Source_filename, } # basic participant info self.info_participant_dict = { "Name": basic_info.Participant.name, "Birthday": basic_info.Participant.birthday, "Gender": basic_info.Participant.sex, } # basic meg info self.info_meg_list = [("Channel Type", "Value"), ("Mag", meg_info.n_mag), ("Grad", meg_info.n_grad), ("Stim", meg_info.n_stim), ("EEG", meg_info.n_eeg), ("ECG", meg_info.n_ecg), ("EOG", meg_info.n_eog), ("Digitized points", meg_info.n_dig) ] # quality reference quality_ref_details = self.report_data.Quality_Ref.details quality_ref_overview = self.report_data.Quality_Ref.category_scores # msqm score self.msqm_score = self._format_msqm_score(self.report_data.Quality_Ref.msqm_score) clogger.info("MSQM score: {}".format(self.msqm_score)) # format css for msqm score self.msqm_score_css = self._css_style_for_msqm_score() # overview of category metrics self.overview_quality_list = [ ("Quality Indices", "Value", "Ref Value", "Status"), ] for k, v in quality_ref_overview.items(): hint = self._get_hint(v) self.overview_quality_list.append((METRICS_REPORT_MAPPING[k], f"{v:.3f}", [0, 1], hint)) # time domain, frequency domain etc. html_table_col_names = ("Quality Indices", "Quality Score", "Value", "Ref Value", "Status") metric_cate_name = quality_ref_overview.keys() for cate_name in metric_cate_name: metric_column_names = METRICS_COLUMNS[cate_name] quality_list = [html_table_col_names] try: for m_cn in metric_column_names: m_scores = quality_ref_details[m_cn] quality_score = m_scores['quality_score'] value = m_scores['metric_score'] lower_bound = m_scores['lower_bound'] upper_bound = m_scores['upper_bound'] hint = m_scores['hint'] # replace metric name in html report. if m_cn in METRICS_MAPPING.keys(): m_cn = METRICS_MAPPING[m_cn] value = self._format_number(value) lower_bound = self._format_number(lower_bound) upper_bound = self._format_number(upper_bound) content = (m_cn, f"{quality_score:.3f}", value, f"[{lower_bound}, {upper_bound}]", hint) quality_list.append(content) except Exception as e: clogger.error(e) if cate_name == "time_domain": self.time_quality_list = quality_list elif cate_name == "frequency_domain": self.freq_quality_list = quality_list elif cate_name == "entropy": self.entropy_quality_list = quality_list elif cate_name == "fractal": self.fractal_quality_list = quality_list elif cate_name == "artifacts": self.artifacts_quality_list = quality_list self.overview_tabs = [("Overview", "overview"), ("Time Series", "timeseries"), ("Frequencies", "frequencies"), ("Entropy", "entropy"), ("Fractal", "fractal"), ("Artifacts", "artifacts")] # self.overview_tabs = ["view", "info", "meg"] self.overview_quality_dict = { } self.footer = 'MEG Quality Report Generated by <a href="https://github.com/liaopan/msqms">msqms</a>.' @staticmethod def _format_msqm_score(score): """ Format the MSQM score into a string representation. Parameters ---------- score : float The MSQM score to be formatted. Returns ------- str The formatted MSQM score with a corresponding label (e.g., 'Bad', 'Good', etc.). """ score = score * 100 if score < 40: return f"{score:.2f}/Bad" elif score >= 40 and score < 60: return f"{score:.2f}/Poor" elif score >= 60 and score < 80: return f"{score:.2f}/Fair" elif score >= 80 and score < 90: return f"{score:.2f}/Good" elif score >= 90: return f"{score:.2f}/Excellent" @staticmethod def _get_hint(score): """ Provide a hint based on the quality score value. Parameters ---------- score : float The score for which the hint is generated. Returns ------- str The corresponding hint ("Low", "Medium", or "High"). """ if score < 0.6: return "Low" elif score >= 0.6 and score < 0.8: return "Medium" elif score >= 0.8 and score <= 1: return "High" @staticmethod def _format_number(value, threshold=1e3): """ Format the number based on its size. If the absolute value is larger than the threshold or smaller than 1/threshold, the number is formatted using scientific notation. Otherwise, it retains three decimal places. Parameters ---------- value : float The number to be formatted. threshold : float, optional The threshold to decide whether to use scientific notation. Default is 1e6. Returns ------- str The formatted number as a string. """ if abs(value) >= threshold or abs(value) < 1 / threshold: return f"{value:.2e}" # Use scientific notation else: return f"{value:.3f}" # Keep three decimal places def _css_style_for_msqm_score(self): """ Generate a CSS style string based on the MSQM score. Returns ------- str The CSS style string to be used in the HTML template. """ color = None excellent_color = '#4fb332' good_color = '#88cecf' fair_color = '#2ab9cd' poor_color = '#f5af30' bad_color = '#e52f10' score = float(self.msqm_score.split("/")[0]) if "Bad" in self.msqm_score: color = bad_color elif "Poor" in self.msqm_score: color = poor_color elif "Fair" in self.msqm_score: color = fair_color elif "Good" in self.msqm_score: color = good_color elif "Excellent" in self.msqm_score: color = excellent_color if color is not None: style = f"--c:{color};--p:{score:.2f}" else: style = "" return style
[docs] def get_template(self, template_name: str) -> jinja2.Template: """ Load and return the Jinja2 template by name. Parameters ---------- template_name : str The name of the template to load. Returns ------- jinja2.Template The loaded template. """ return self.jinja2_env.get_template(template_name)
[docs] def gen_base_template(self): """ Generate the base HTML template. Returns ------- jinja2.Template The base template for the HTML report. """ return self.get_template('base.html')
[docs] def gen_html_report(self): """ Generate the full HTML report. Returns ------- str The generated HTML report content. """ # navigation settings. self.nav_items = [("INFO", "info"), ("Quality Overview", "overview"), # ("Artifacts", "artifacts"), ("Visual Inspection", "inspection"), # ("ICA", "ica"), ] # get base templates(Main HTML) render_params = { "title": self.nav_title, "nav": True, "nav_items": self.nav_items, "footer": self.footer, "info_title": self.info_title_name, "info_tabs": self.info_tabs, "info_basic": self.info_basic, "info_participant_dict": self.info_participant_dict, "info_meg_list": self.info_meg_list, "msqm_score": self.msqm_score, "overview_title": self.overview_title_name, "overview_tabs": self.overview_tabs, "overview_quality_list": self.overview_quality_list, "artifacts_quality_list": self.artifacts_quality_list, "time_quality_list": self.time_quality_list, "freq_quality_list": self.freq_quality_list, "entropy_quality_list": self.entropy_quality_list, "fractal_quality_list": self.fractal_quality_list, "msqm_score_css": self.msqm_score_css, "report_html_name": Path(self.info_basic["Source filename"]).stem, # "overview_dict": self.overview_dict, } html = self.gen_base_template().render(**render_params) return html
[docs] def gen_nav_html(self): """ Generate the navigation HTML component. Returns ------- str The generated navigation HTML content. """ html = self.get_template("navigation.html") # multi panels self.nav_items = [("Quality Overview", "overview"), ("Artifacts", "artifacts"), ("Visual Inspection", "inspection"), ("ICA", "ica")] html.render(self.nav_items) return html
[docs] def gen_body_html(self): """ Generate the body HTML component. Returns ------- str The generated body HTML content. """ html = self.get_template("navigation.html") # multi panels self.nav_items = [("Quality Overview", "overview"), ("Artifacts", "artifacts"), ("Visual Inspection", "inspection"), ("ICA", "ica")] html.render(self.nav_items) return html
[docs] def render_html(self): """ Render the complete HTML page. Returns ------- str The final rendered HTML content. """ html_page = self.gen_html_report() return html_page
[docs] class SummaryQualityReport(object): """ Generate a summary quality report for multiple MEG files. """ def __init__(self, report_data, minify_html): """ Parameters ---------- report_data : dict The report data to be included in the summary report. minify_html : bool Whether to minify the HTML report. """ self.report_data = report_data self.minify_html = minify_html
[docs] def to_json(self, out_json_path: Union[str, Path]) -> None: """ Write the summary report to a JSON file. Parameters ---------- out_json_path : str or Path The path where the JSON report will be saved. """ with tqdm(total=1, desc="Render JSON") as pbar: report_data = json.dumps(self.report_data, indent=4, default=str) pbar.update() self._to_file(report_data=report_data, output_file=out_json_path)
[docs] def to_html(self, out_html_path: Union[str, Path]) -> None: """ Write the summary report to an HTML file. Parameters ---------- out_html_path : str or Path The path where the HTML report will be saved. """ with tqdm(total=1, desc="Render Summary Html") as pbar: html = SummaryHtmlReport(self.report_data).render_html() if self.minify_html: import minify_html html = minify_html.minify(html, minify_js=True, minify_css=True, ) pbar.update() self._to_file(report_data=html, output_file=out_html_path)
def _to_file(self, report_data: str, output_file: Union[str, Path]) -> None: """ Write the report data to a file. Parameters ---------- report_data : str The report data to be written to the file. output_file : str or Path The path to the file where the report will be saved. """ if not isinstance(output_file, Path): output_file = Path(str(output_file)) if output_file.suffix not in [".html", ".json"]: suffix = output_file.suffix output_file = output_file.with_suffix(".html") clogger.warning( f"Extension {suffix} not supported. We use .html instead." f"To remove this warning, please use .html or .json." ) with tqdm(total=1, desc="Export summary report to file") as pbar: output_file.write_text(report_data, encoding="utf-8") pbar.update() clogger.info(f"Export summary report path:{output_file}")
[docs] class SummaryHtmlReport(object): """ Generate an HTML summary report for multiple MEG quality metrics. """ def __init__(self, report_data): """ Initialize the HTML summary report generator. Parameters ---------- report_data : dict The data to be included in the HTML summary report. """ # Init Jinja package_loader = PackageLoader(package_name="msqms", package_path="reports/templates") self.jinja2_env = Environment(loader=package_loader) self.report_data = report_data self.nav_title = "<strong>MEG Quality Summary Report</strong>" self.footer = 'MEG Quality Summary Report Generated by <a href="https://github.com/liaopan/msqms">msqms</a>.' # Prepare data for template # Convert Box objects to regular dicts/lists to avoid method name conflicts if hasattr(self.report_data, 'reports'): reports_raw = self.report_data.reports # Convert to list of dicts if isinstance(reports_raw, list): self.reports = [dict(r) if hasattr(r, 'keys') else r for r in reports_raw] else: self.reports = list(reports_raw) if hasattr(reports_raw, '__iter__') and not isinstance(reports_raw, (str, bytes)) else [reports_raw] else: self.reports = [] if hasattr(self.report_data, 'summary_stats'): # Convert Box to dict summary_stats = self.report_data.summary_stats if isinstance(summary_stats, dict): self.summary_stats = summary_stats elif hasattr(summary_stats, 'to_dict'): self.summary_stats = summary_stats.to_dict() else: # Convert Box to dict manually self.summary_stats = dict(summary_stats) else: self.summary_stats = {} # Format data for visualization self._prepare_visualization_data() def _prepare_visualization_data(self): """Prepare data for JavaScript visualization.""" # Convert Box objects to dicts to avoid method name conflicts summary_stats_dict = dict(self.summary_stats) if hasattr(self.summary_stats, 'keys') else self.summary_stats reports_list = list(self.reports) if hasattr(self.reports, '__iter__') and not isinstance(self.reports, (str, bytes)) else self.reports # MSQM scores data msqm_scores = summary_stats_dict.get('msqm_scores', []) stats_dict = summary_stats_dict.get('statistics', {}) self.msqm_scores_data = { 'values': [float(score) * 100 for score in msqm_scores], 'labels': [report.get('filename', '') if isinstance(report, dict) else getattr(report, 'filename', '') for report in reports_list], 'mean': float(stats_dict.get('msqm_mean', 0) * 100), 'std': float(stats_dict.get('msqm_std', 0) * 100), 'min': float(stats_dict.get('msqm_min', 0) * 100), 'max': float(stats_dict.get('msqm_max', 0) * 100), 'median': float(stats_dict.get('msqm_median', 0) * 100) } # Category scores data self.category_scores_data = {} category_scores_dict = summary_stats_dict.get('category_scores', {}) category_stats_dict = stats_dict.get('category_stats', {}) for category, display_name in METRICS_REPORT_MAPPING.items(): if category in category_scores_dict and len(category_scores_dict[category]) > 0: cat_scores = category_scores_dict[category] if category in category_stats_dict: stats = category_stats_dict[category] self.category_scores_data[category] = { 'display_name': display_name, 'values': [float(score) * 100 for score in cat_scores], 'labels': [report.get('filename', '') if isinstance(report, dict) else getattr(report, 'filename', '') for report in reports_list], 'mean': float(stats.get('mean', 0) * 100), 'std': float(stats.get('std', 0) * 100), 'min': float(stats.get('min', 0) * 100), 'max': float(stats.get('max', 0) * 100), 'median': float(stats.get('median', 0) * 100) }
[docs] def get_template(self, template_name: str) -> jinja2.Template: """ Load and return the Jinja2 template by name. Parameters ---------- template_name : str The name of the template to load. Returns ------- jinja2.Template The loaded template. """ return self.jinja2_env.get_template(template_name)
[docs] def render_html(self): """ Render the complete HTML summary page. Returns ------- str The final rendered HTML content. """ # Convert reports to list of dicts to avoid Box issues reports_list = [] for report in self.reports: if isinstance(report, dict): reports_list.append(report) elif hasattr(report, 'to_dict'): reports_list.append(report.to_dict()) else: # Convert Box or object to dict reports_list.append({k: getattr(report, k) for k in dir(report) if not k.startswith('_') and not callable(getattr(report, k, None))}) # Ensure all data structures are plain Python types render_params = { "title": self.nav_title, "nav": True, "nav_items": [("Summary", "summary"), ("Individual Reports", "individual")], "footer": self.footer, "reports": reports_list, "summary_stats": dict(self.summary_stats) if hasattr(self.summary_stats, 'keys') else self.summary_stats, "msqm_scores_data": dict(self.msqm_scores_data), "category_scores_data": {k: dict(v) for k, v in self.category_scores_data.items()}, "total_files": len(reports_list), "metrics_mapping": METRICS_REPORT_MAPPING } template = self.get_template('summary_report.html') html = template.render(**render_params) return html