ui: video diff tool (#36737)

* video diff

* format

* duplicate

* try

* WINDOWED

* ?

* correct res

* Revert "correct res"

This reverts commit f90991192fce93a31d1b581a4f0ff93a7a972337.

* save to report/

* add duplicate

* work?

* fix

* more

* more

* and this

* ffmpeg

* branch

* uncmt

* test preview

* Revert "uncmt"

This reverts commit b02404dbbe515fd861717f831c7bb0243442ddbc.

* create openpilot_master_ui_mici_raylib

* ahh

* push to master

* copy and always run

* test

* does cmt break it?

* who did this

* fix?

* fix that

* hmm

* hmm

* ah this was moving it, and then the job below didn't run on master

* google ai overview lied to me

* use markdown to start

* need to add to one branch

* ????

* oof

* no

* this work?

* test

* try this

* clean up master branch name

* more cleanup

more cleanup

* don't fail for no diff!

don't fail for no diff!

* back

* add to cmt

* test it

* should work

* fix that

* back

* clean up

* clean up

* save to report

* pull_request_target

* sort

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
This commit is contained in:
Adeeb Shihadeh
2025-12-08 18:39:47 -08:00
committed by GitHub
parent 7119412d35
commit fb807cc007
7 changed files with 520 additions and 2 deletions

View File

@@ -0,0 +1,151 @@
name: "mici raylib ui preview"
on:
push:
branches:
- master
pull_request_target:
types: [assigned, opened, synchronize, reopened, edited]
branches:
- 'master'
paths:
- 'selfdrive/assets/**'
- 'selfdrive/ui/**'
- 'system/ui/**'
workflow_dispatch:
env:
UI_JOB_NAME: "Create mici raylib UI Report"
REPORT_NAME: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && 'master' || github.event.number }}
SHA: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.sha || github.event.pull_request.head.sha }}
BRANCH_NAME: "openpilot/pr-${{ github.event.number }}-mici-raylib-ui"
MASTER_BRANCH_NAME: "openpilot_master_ui_mici_raylib"
# All report files are pushed here
REPORT_FILES_BRANCH_NAME: "mici-raylib-ui-reports"
jobs:
preview:
if: github.repository == 'commaai/openpilot'
name: preview
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
pull-requests: write
actions: read
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Waiting for ui generation to end
uses: lewagon/wait-on-check-action@v1.3.4
with:
ref: ${{ env.SHA }}
check-name: ${{ env.UI_JOB_NAME }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
allowed-conclusions: success
wait-interval: 20
- name: Getting workflow run ID
id: get_run_id
run: |
echo "run_id=$(curl https://api.github.com/repos/${{ github.repository }}/commits/${{ env.SHA }}/check-runs | jq -r '.check_runs[] | select(.name == "${{ env.UI_JOB_NAME }}") | .html_url | capture("(?<number>[0-9]+)") | .number')" >> $GITHUB_OUTPUT
- name: Getting proposed ui # filename: pr_ui/mici_ui_replay.mp4
id: download-artifact
uses: dawidd6/action-download-artifact@v6
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
run_id: ${{ steps.get_run_id.outputs.run_id }}
search_artifacts: true
name: mici-raylib-report-1-${{ env.REPORT_NAME }}
path: ${{ github.workspace }}/pr_ui
- name: Getting master ui # filename: master_ui_raylib/mici_ui_replay.mp4
uses: actions/checkout@v4
with:
repository: commaai/ci-artifacts
ssh-key: ${{ secrets.CI_ARTIFACTS_DEPLOY_KEY }}
path: ${{ github.workspace }}/master_ui_raylib
ref: ${{ env.MASTER_BRANCH_NAME }}
- name: Saving new master ui
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
working-directory: ${{ github.workspace }}/master_ui_raylib
run: |
git checkout --orphan=new_master_ui_mici_raylib
git rm -rf *
git branch -D ${{ env.MASTER_BRANCH_NAME }}
git branch -m ${{ env.MASTER_BRANCH_NAME }}
git config user.name "GitHub Actions Bot"
git config user.email "<>"
mv ${{ github.workspace }}/pr_ui/* .
git add .
git commit -m "mici raylib video for commit ${{ env.SHA }}"
git push origin ${{ env.MASTER_BRANCH_NAME }} --force
- name: Setup FFmpeg
uses: AnimMouse/setup-ffmpeg@ae28d57dabbb148eff63170b6bf7f2b60062cbae
- name: Finding diff
if: github.event_name == 'pull_request_target'
id: find_diff
run: |
# Find the video file from PR
pr_video="${{ github.workspace }}/pr_ui/mici_ui_replay_proposed.mp4"
mv "${{ github.workspace }}/pr_ui/mici_ui_replay.mp4" "$pr_video"
master_video="${{ github.workspace }}/pr_ui/mici_ui_replay_master.mp4"
mv "${{ github.workspace }}/master_ui_raylib/mici_ui_replay.mp4" "$master_video"
# Run report
export PYTHONPATH=${{ github.workspace }}
baseurl="https://github.com/commaai/ci-artifacts/raw/refs/heads/${{ env.BRANCH_NAME }}"
diff_exit_code=0
python3 ${{ github.workspace }}/selfdrive/ui/tests/diff/diff.py "${{ github.workspace }}/pr_ui/mici_ui_replay_master.mp4" "${{ github.workspace }}/pr_ui/mici_ui_replay_proposed.mp4" "diff.html" --basedir "$baseurl" --no-open || diff_exit_code=$?
# Copy diff report files
cp ${{ github.workspace }}/selfdrive/ui/tests/diff/report/diff.html ${{ github.workspace }}/pr_ui/
cp ${{ github.workspace }}/selfdrive/ui/tests/diff/report/diff.mp4 ${{ github.workspace }}/pr_ui/
REPORT_URL="https://commaai.github.io/ci-artifacts/diff_pr_${{ github.event.number }}.html"
if [ $diff_exit_code -eq 0 ]; then
DIFF="✅ Videos are identical! [View Diff Report]($REPORT_URL)"
else
DIFF="❌ <strong>Videos differ!</strong> [View Diff Report]($REPORT_URL)"
fi
echo "DIFF=$DIFF" >> "$GITHUB_OUTPUT"
- name: Saving proposed ui
if: github.event_name == 'pull_request_target'
working-directory: ${{ github.workspace }}/master_ui_raylib
run: |
# Overwrite PR branch w/ proposed ui, and master ui at this point in time for future reference
git config user.name "GitHub Actions Bot"
git config user.email "<>"
git checkout --orphan=${{ env.BRANCH_NAME }}
git rm -rf *
mv ${{ github.workspace }}/pr_ui/* .
git add .
git commit -m "mici raylib video for PR #${{ github.event.number }}"
git push origin ${{ env.BRANCH_NAME }} --force
# Append diff report to report files branch
git fetch origin ${{ env.REPORT_FILES_BRANCH_NAME }}
git checkout ${{ env.REPORT_FILES_BRANCH_NAME }}
cp ${{ github.workspace }}/selfdrive/ui/tests/diff/report/diff.html diff_pr_${{ github.event.number }}.html
git add diff_pr_${{ github.event.number }}.html
git commit -m "mici raylib ui diff report for PR #${{ github.event.number }}" || echo "No changes to commit"
git push origin ${{ env.REPORT_FILES_BRANCH_NAME }}
- name: Comment Video on PR
if: github.event_name == 'pull_request_target'
uses: thollander/actions-comment-pull-request@v2
with:
message: |
<!-- _(run_id_video_mici_raylib **${{ github.run_id }}**)_ -->
## mici raylib UI Preview
${{ steps.find_diff.outputs.DIFF }}
comment_tag: run_id_video_mici_raylib
pr_number: ${{ github.event.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -265,3 +265,29 @@ jobs:
with:
name: raylib-report-${{ inputs.run_number || '1' }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && 'master' || github.event.number }}
path: selfdrive/ui/tests/test_ui/raylib_report/screenshots
create_mici_raylib_ui_report:
name: Create mici raylib UI Report
runs-on: ${{
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]')
|| fromJSON('["ubuntu-24.04"]') }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
- name: Build openpilot
run: ${{ env.RUN }} "scons -j$(nproc)"
- name: Create mici raylib UI Report
run: >
${{ env.RUN }} "PYTHONWARNINGS=ignore &&
source selfdrive/test/setup_xvfb.sh &&
WINDOWED=1 python3 selfdrive/ui/tests/diff/replay.py"
- name: Upload Raylib UI Report
uses: actions/upload-artifact@v4
with:
name: mici-raylib-report-${{ inputs.run_number || '1' }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && 'master' || github.event.number }}
path: selfdrive/ui/tests/diff/report

View File

@@ -85,6 +85,7 @@ docs = [
]
testing = [
"coverage",
"hypothesis ==6.47.*",
"mypy",
"pytest",

View File

@@ -2,3 +2,8 @@ test
test_translations
test_ui/report_1
test_ui/raylib_report
diff/*.mp4
diff/*.html
diff/.coverage
diff/htmlcov/

201
selfdrive/ui/tests/diff/diff.py Executable file
View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
import os
import sys
import subprocess
import tempfile
import base64
import webbrowser
import argparse
from pathlib import Path
from openpilot.common.basedir import BASEDIR
DIFF_OUT_DIR = Path(BASEDIR) / "selfdrive" / "ui" / "tests" / "diff" / "report"
def extract_frames(video_path, output_dir):
output_pattern = str(output_dir / "frame_%04d.png")
cmd = ['ffmpeg', '-i', video_path, '-vsync', '0', output_pattern, '-y']
subprocess.run(cmd, capture_output=True, check=True)
frames = sorted(output_dir.glob("frame_*.png"))
return frames
def compare_frames(frame1_path, frame2_path):
result = subprocess.run(['cmp', '-s', frame1_path, frame2_path])
return result.returncode == 0
def frame_to_data_url(frame_path):
with open(frame_path, 'rb') as f:
data = f.read()
return f"data:image/png;base64,{base64.b64encode(data).decode()}"
def create_diff_video(video1, video2, output_path):
"""Create a diff video using ffmpeg blend filter with difference mode."""
print("Creating diff video...")
cmd = ['ffmpeg', '-i', video1, '-i', video2, '-filter_complex', '[0:v]blend=all_mode=difference', '-vsync', '0', '-y', output_path]
subprocess.run(cmd, capture_output=True, check=True)
def find_differences(video1, video2):
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
print(f"Extracting frames from {video1}...")
frames1_dir = tmpdir / "frames1"
frames1_dir.mkdir()
frames1 = extract_frames(video1, frames1_dir)
print(f"Extracting frames from {video2}...")
frames2_dir = tmpdir / "frames2"
frames2_dir.mkdir()
frames2 = extract_frames(video2, frames2_dir)
if len(frames1) != len(frames2):
print(f"WARNING: Frame count mismatch: {len(frames1)} vs {len(frames2)}")
min_frames = min(len(frames1), len(frames2))
frames1 = frames1[:min_frames]
frames2 = frames2[:min_frames]
print(f"Comparing {len(frames1)} frames...")
different_frames = []
frame_data = []
for i, (f1, f2) in enumerate(zip(frames1, frames2, strict=False)):
is_different = not compare_frames(f1, f2)
if is_different:
different_frames.append(i)
if i < 10 or i >= len(frames1) - 10 or is_different:
frame_data.append({'index': i, 'different': is_different, 'frame1_url': frame_to_data_url(f1), 'frame2_url': frame_to_data_url(f2)})
return different_frames, frame_data, len(frames1)
def generate_html_report(video1, video2, basedir, different_frames, frame_data, total_frames):
chunks = []
if different_frames:
current_chunk = [different_frames[0]]
for i in range(1, len(different_frames)):
if different_frames[i] == different_frames[i - 1] + 1:
current_chunk.append(different_frames[i])
else:
chunks.append(current_chunk)
current_chunk = [different_frames[i]]
chunks.append(current_chunk)
result_text = (
f"✅ Videos are identical! ({total_frames} frames)"
if len(different_frames) == 0
else f"❌ Found {len(different_frames)} different frames out of {total_frames} total ({(len(different_frames) / total_frames * 100):.1f}%)"
)
html = f"""<h2>UI Diff</h2>
<table>
<tr>
<td width='33%'>
<p><strong>Video 1</strong></p>
<video id='video1' width='100%' autoplay muted loop onplay='syncVideos()'>
<source src='{os.path.join(basedir, os.path.basename(video1))}' type='video/mp4'>
Your browser does not support the video tag.
</video>
</td>
<td width='33%'>
<p><strong>Video 2</strong></p>
<video id='video2' width='100%' autoplay muted loop onplay='syncVideos()'>
<source src='{os.path.join(basedir, os.path.basename(video2))}' type='video/mp4'>
Your browser does not support the video tag.
</video>
</td>
<td width='33%'>
<p><strong>Pixel Diff</strong></p>
<video id='diffVideo' width='100%' autoplay muted loop>
<source src='{os.path.join(basedir, 'diff.mp4')}' type='video/mp4'>
Your browser does not support the video tag.
</video>
</td>
</tr>
</table>
<script>
function syncVideos() {{
const video1 = document.getElementById('video1');
const video2 = document.getElementById('video2');
const diffVideo = document.getElementById('diffVideo');
video1.currentTime = video2.currentTime = diffVideo.currentTime;
}}
video1.addEventListener('timeupdate', () => {{
if (Math.abs(video1.currentTime - video2.currentTime) > 0.1) {{
video2.currentTime = video1.currentTime;
}}
if (Math.abs(video1.currentTime - diffVideo.currentTime) > 0.1) {{
diffVideo.currentTime = video1.currentTime;
}}
}});
video2.addEventListener('timeupdate', () => {{
if (Math.abs(video2.currentTime - video1.currentTime) > 0.1) {{
video1.currentTime = video2.currentTime;
}}
if (Math.abs(video2.currentTime - diffVideo.currentTime) > 0.1) {{
diffVideo.currentTime = video2.currentTime;
}}
}});
diffVideo.addEventListener('timeupdate', () => {{
if (Math.abs(diffVideo.currentTime - video1.currentTime) > 0.1) {{
video1.currentTime = diffVideo.currentTime;
video2.currentTime = diffVideo.currentTime;
}}
}});
</script>
<hr>
<p><strong>Results:</strong> {result_text}</p>
"""
return html
def main():
parser = argparse.ArgumentParser(description='Compare two videos and generate HTML diff report')
parser.add_argument('video1', help='First video file')
parser.add_argument('video2', help='Second video file')
parser.add_argument('output', nargs='?', default='diff.html', help='Output HTML file (default: diff.html)')
parser.add_argument("--basedir", type=str, help="Base directory for output", default="")
parser.add_argument('--no-open', action='store_true', help='Do not open HTML report in browser')
args = parser.parse_args()
os.makedirs(DIFF_OUT_DIR, exist_ok=True)
print("=" * 60)
print("VIDEO DIFF - HTML REPORT")
print("=" * 60)
print(f"Video 1: {args.video1}")
print(f"Video 2: {args.video2}")
print(f"Output: {args.output}")
print()
# Create diff video
diff_video_path = os.path.join(os.path.dirname(args.output), DIFF_OUT_DIR / "diff.mp4")
create_diff_video(args.video1, args.video2, diff_video_path)
different_frames, frame_data, total_frames = find_differences(args.video1, args.video2)
if different_frames is None:
sys.exit(1)
print()
print("Generating HTML report...")
html = generate_html_report(args.video1, args.video2, args.basedir, different_frames, frame_data, total_frames)
with open(DIFF_OUT_DIR / args.output, 'w') as f:
f.write(html)
# Open in browser by default
if not args.no_open:
print(f"Opening {args.output} in browser...")
webbrowser.open(f'file://{os.path.abspath(DIFF_OUT_DIR / args.output)}')
return 0 if len(different_frames) == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import os
import time
import coverage
import pyray as rl
from openpilot.selfdrive.ui.tests.diff.diff import DIFF_OUT_DIR
os.environ["RECORD"] = "1"
if "RECORD_OUTPUT" not in os.environ:
os.environ["RECORD_OUTPUT"] = "mici_ui_replay.mp4"
os.environ["RECORD_OUTPUT"] = os.path.join(DIFF_OUT_DIR, os.environ["RECORD_OUTPUT"])
from openpilot.common.params import Params
from openpilot.system.version import terms_version, training_version
from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
FPS = 60
HEADLESS = os.getenv("WINDOWED", "0") == "1"
SCRIPT = [
(0, None),
(FPS * 1, (100, 100)),
(FPS * 2, None),
]
def setup_state():
params = Params()
params.put("HasAcceptedTerms", terms_version)
params.put("CompletedTrainingVersion", training_version)
params.put("DongleId", "test123456789")
params.put("UpdaterCurrentDescription", "0.10.1 / test-branch / abc1234 / Nov 30")
return None
def inject_click(x, y):
press_event = MouseEvent(pos=MousePos(x, y), slot=0, left_pressed=True, left_released=False, left_down=True, t=time.monotonic())
release_event = MouseEvent(pos=MousePos(x, y), slot=0, left_pressed=False, left_released=True, left_down=False, t=time.monotonic())
with gui_app._mouse._lock:
gui_app._mouse._events.append(press_event)
gui_app._mouse._events.append(release_event)
def run_replay():
setup_state()
os.makedirs(DIFF_OUT_DIR, exist_ok=True)
if not HEADLESS:
rl.set_config_flags(rl.FLAG_WINDOW_HIDDEN)
gui_app.init_window("ui diff test", fps=FPS)
main_layout = MiciMainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
frame = 0
script_index = 0
for should_render in gui_app.render():
while script_index < len(SCRIPT) and SCRIPT[script_index][0] == frame:
_, coords = SCRIPT[script_index]
if coords is not None:
inject_click(*coords)
script_index += 1
ui_state.update()
if should_render:
main_layout.render()
frame += 1
if script_index >= len(SCRIPT):
break
gui_app.close()
print(f"Total frames: {frame}")
print(f"Video saved to: {os.environ['RECORD_OUTPUT']}")
def main():
cov = coverage.coverage(source=['openpilot.selfdrive.ui.mici'])
with cov.collect():
run_replay()
cov.stop()
cov.save()
cov.report()
cov.html_report(directory=os.path.join(DIFF_OUT_DIR, 'htmlcov'))
print("HTML report: htmlcov/index.html")
if __name__ == "__main__":
main()

41
uv.lock generated
View File

@@ -371,6 +371,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
]
[[package]]
name = "coverage"
version = "7.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" },
{ url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" },
{ url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" },
{ url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" },
{ url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" },
{ url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" },
{ url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" },
{ url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" },
{ url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" },
{ url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" },
{ url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" },
{ url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" },
{ url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" },
{ url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" },
{ url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" },
{ url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" },
]
[[package]]
name = "crcmod"
version = "1.7"
@@ -1349,6 +1384,7 @@ docs = [
]
testing = [
{ name = "codespell" },
{ name = "coverage" },
{ name = "hypothesis" },
{ name = "mypy" },
{ name = "pre-commit-hooks" },
@@ -1364,7 +1400,7 @@ testing = [
{ name = "ruff" },
]
tools = [
{ name = "dearpygui" },
{ name = "dearpygui", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "metadrive-simulator", marker = "platform_machine != 'aarch64'" },
]
@@ -1378,10 +1414,11 @@ requires-dist = [
{ name = "casadi", specifier = ">=3.6.6" },
{ name = "cffi" },
{ name = "codespell", marker = "extra == 'testing'" },
{ name = "coverage", marker = "extra == 'testing'" },
{ name = "crcmod" },
{ name = "cython" },
{ name = "dbus-next", marker = "extra == 'dev'" },
{ name = "dearpygui", marker = "extra == 'tools'", specifier = ">=2.1.0" },
{ name = "dearpygui", marker = "(platform_machine != 'aarch64' and extra == 'tools') or (sys_platform != 'linux' and extra == 'tools')", specifier = ">=2.1.0" },
{ name = "dictdiffer", marker = "extra == 'dev'" },
{ name = "future-fstrings" },
{ name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" },