Davidkamaa
Member
Full Member
- Messages
- 31
- Reaction score
- 0
Hey everyone! I'm David, and I've been in the digital dentistry game for a good few years now. And here's the thing that's always kinda bugged me: we get these intraoral scans from docs, and well... you know how it is. Either the bite is slightly open, or they're only touching on one random point on the side, or it's just a mess. Tons of reasons: bad stitching of left/right scans in the intraoral scanner itself, patient moved their jaw, doc wasn't paying enough attention, you name it.
So for years, I've been wondering: damn, we have a digital POINT CLOUD! Can't we just, like, mathematically figure out where these jaws are supposed to meet, find those occlusal contacts? At some point, I found myself thinking about how to solve this problem and started working on the script from time to time at a relaxed pace. Eventually, the results seemed interesting to me, so I decided to share them with the world.
The code isn’t very well optimized, and all the heavy logic is processed by the CPU instead of the GPU, which would’ve been preferable.
So, How Does This Thing (Try To) Work?
The Anchor - Upper Jaw (UJ): We treat the upper jaw as fixed. Load it up, and it stays put.
The Mover - Lower Jaw (LJ): This is the one we'll be nudging, rotating, and generally trying to seat properly against the UJ.
The Logic Behind the "Seating" (My attempt at a "for dummies" explanation):
The program iteratively tries to improve the LJ's position based on a few key metrics:
1. Fighting Penetrations:
This is priority #1. If points on the LJ have "sunk" into the UJ deeper than a set threshold (max_penetration_depth), the program tries to "push" them out.
It figures out the surface normal of the UJ at the penetration point and nudges the LJ along that vector. The "oomph" of this push can be tweaked (push_out_distance_factor).
The goal: Jaws shouldn't pass through each other, obviously.
2. Seeking "Planar Contacts":
These are what I consider the "quality" contacts. Not just a single point touching, but small surface areas on the UJ and LJ coming together.
The program looks for points on the LJ that are:
Super close to the UJ (controlled by planar_contact_proximity_threshold).
The surface normals at these points on the UJ and LJ are pretty much facing each other (technically, they're nearly anti-parallel, angle close to 180 degrees, tweaked with planar_contact_normal_dot_product_threshold).
Such a point has a few "buddies" nearby (min_neighbors_for_significant_planar_contact) that also meet these criteria. This is to avoid random single-point flukes and find actual contact zones.
The goal: Find and maximize these natural seating areas.
3. "Occlusal Contacts":
These are simpler. Just points on the LJ that are very close to the UJ (occlusal_contact_threshold) but aren't planar contacts and aren't penetrating.
The goal: Account for all possible contact points, even if they don't form large patches.
4. Moving the LJ (ICP-ish Approach):
If there are no major penetrations, the program tries to "pull" the LJ towards the UJ.
It picks "candidate" points on the LJ that are within a reasonable distance of the UJ (icp_max_distance) and aren't penetrating.
For these points, it finds their closest counterparts on the UJ.
Then, it uses an ICP (Iterative Closest Point) algorithm – specifically, a Point-to-Plane variation if normals are available – to calculate a small transformation (translation and rotation) for the LJ that would bring these point sets closer.
The "aggressiveness" of this pull can be adjusted (icp_step_scale_translation for movement, icp_step_scale_rotation for rotation). If you have scipy installed, the rotation part is a bit smarter; otherwise, it's translation-only for this step.
The goal: Iteratively snug the LJ up against the UJ by minimizing distances.
5. Tackling "Getting Stuck" (Stagnation & Exploration):
Sometimes, the algorithm can get stuck in a local optimum where tiny moves don't make things better.
If several iterations pass without improvement (stagnation_iter_limit), the program can switch to an "exploration" mode (exploration_active_next_step – this is a state, triggered if stagnation occurs).
In this mode, it gives the LJ a little random nudge (a bit of rotation by explore_rotation_deg and translation by explore_translation_mm) to try and jolt it out of that rut and hopefully find a better overall solution.
The Workflow:
Load STL/PLY Files:
Browse Lower/Upper: Select your lower and upper jaw files. The paths will appear in the input fields.
Process Controls:
1.Start/Stop Viz: The main button. Launches the Open3D window and loads the models. While the window is running, this button becomes "
Stop Viz" to close it. IMPORTANT: While visualization is running, you CANNOT change settings or select files. Stop it first, then make changes.
2.Iterate Batch: This is the button to run one "batch" of iterations (the number of iterations per batch is set in settings: Iter/Batch). Click it – the mandible moves a bit according to the logic above. Click again – it moves more. Kep an eye on the numbers in "Current Lower Jaw Stats."
3.Update Heatmap: Recalculates and displays contacts as colors on the LOWER jaw (if visible) or UPPER jaw (if lower is hidden and upper is visible).
Blue (default): Occlusal contacts.
Green (default): Planar contacts.
Gradient (blue to red, default jet colormap): Shows distance to the maxilla if it's greater than the occlusal threshold but less than gradient_max_dist.
4. Before/After: Toggles the mandible display between its initial position ("Before" – as loaded) and the current, post-iteration position ("After"). Useful for comparison. The heatmap updates automatically on toggle.
5. Save ZIP: Saves the result. The current mandible position (even if you're viewing "Before," "After" will be saved) and the original maxilla (as it was in the window) are packed into a ZIP archive as STL files.
6. Lower/Upper Vis: Toggle buttons to hide/show the mandible and maxilla in the 3D window. Sometimes useful to hide one jaw to better see contacts on the other.
Current status:
It's starting to give plausible results. If I feed it a pair of scans that are slightly off, it often brings them to a state where contacts appear where you'd logically expect them.
Here's an example on real scans: The intraoral scans were initially shifted significantly to the side and upwards relative to each other. For validation, these models were 3D printed and mounted in an articulator using a physical bite registration. The program managed to align the digital scans to a position that matched (lets say on 70%) physically verified bite.


I'd really appreciate it if you could:
Thanks in advance, everyone!
p.s obviously, scans aren't perfect impressions. They can be inaccurate, have artifacts, be "noisy," and sometimes the model can be "warped" along the arch (especially full arches from an intraoral scanner). My code won't magically fix global problem. But I've tried to make the code "work around" and correct for small irregularities, "roughness," and minor discrepancies.
So for years, I've been wondering: damn, we have a digital POINT CLOUD! Can't we just, like, mathematically figure out where these jaws are supposed to meet, find those occlusal contacts? At some point, I found myself thinking about how to solve this problem and started working on the script from time to time at a relaxed pace. Eventually, the results seemed interesting to me, so I decided to share them with the world.
The code isn’t very well optimized, and all the heavy logic is processed by the CPU instead of the GPU, which would’ve been preferable.
So, How Does This Thing (Try To) Work?
The Anchor - Upper Jaw (UJ): We treat the upper jaw as fixed. Load it up, and it stays put.
The Mover - Lower Jaw (LJ): This is the one we'll be nudging, rotating, and generally trying to seat properly against the UJ.
The Logic Behind the "Seating" (My attempt at a "for dummies" explanation):
The program iteratively tries to improve the LJ's position based on a few key metrics:
1. Fighting Penetrations:
This is priority #1. If points on the LJ have "sunk" into the UJ deeper than a set threshold (max_penetration_depth), the program tries to "push" them out.
It figures out the surface normal of the UJ at the penetration point and nudges the LJ along that vector. The "oomph" of this push can be tweaked (push_out_distance_factor).
The goal: Jaws shouldn't pass through each other, obviously.
2. Seeking "Planar Contacts":
These are what I consider the "quality" contacts. Not just a single point touching, but small surface areas on the UJ and LJ coming together.
The program looks for points on the LJ that are:
Super close to the UJ (controlled by planar_contact_proximity_threshold).
The surface normals at these points on the UJ and LJ are pretty much facing each other (technically, they're nearly anti-parallel, angle close to 180 degrees, tweaked with planar_contact_normal_dot_product_threshold).
Such a point has a few "buddies" nearby (min_neighbors_for_significant_planar_contact) that also meet these criteria. This is to avoid random single-point flukes and find actual contact zones.
The goal: Find and maximize these natural seating areas.
3. "Occlusal Contacts":
These are simpler. Just points on the LJ that are very close to the UJ (occlusal_contact_threshold) but aren't planar contacts and aren't penetrating.
The goal: Account for all possible contact points, even if they don't form large patches.
4. Moving the LJ (ICP-ish Approach):
If there are no major penetrations, the program tries to "pull" the LJ towards the UJ.
It picks "candidate" points on the LJ that are within a reasonable distance of the UJ (icp_max_distance) and aren't penetrating.
For these points, it finds their closest counterparts on the UJ.
Then, it uses an ICP (Iterative Closest Point) algorithm – specifically, a Point-to-Plane variation if normals are available – to calculate a small transformation (translation and rotation) for the LJ that would bring these point sets closer.
The "aggressiveness" of this pull can be adjusted (icp_step_scale_translation for movement, icp_step_scale_rotation for rotation). If you have scipy installed, the rotation part is a bit smarter; otherwise, it's translation-only for this step.
The goal: Iteratively snug the LJ up against the UJ by minimizing distances.
5. Tackling "Getting Stuck" (Stagnation & Exploration):
Sometimes, the algorithm can get stuck in a local optimum where tiny moves don't make things better.
If several iterations pass without improvement (stagnation_iter_limit), the program can switch to an "exploration" mode (exploration_active_next_step – this is a state, triggered if stagnation occurs).
In this mode, it gives the LJ a little random nudge (a bit of rotation by explore_rotation_deg and translation by explore_translation_mm) to try and jolt it out of that rut and hopefully find a better overall solution.
The Workflow:
Load STL/PLY Files:
Browse Lower/Upper: Select your lower and upper jaw files. The paths will appear in the input fields.
Process Controls:
1.Start/Stop Viz: The main button. Launches the Open3D window and loads the models. While the window is running, this button becomes "
2.Iterate Batch: This is the button to run one "batch" of iterations (the number of iterations per batch is set in settings: Iter/Batch). Click it – the mandible moves a bit according to the logic above. Click again – it moves more. Kep an eye on the numbers in "Current Lower Jaw Stats."
3.Update Heatmap: Recalculates and displays contacts as colors on the LOWER jaw (if visible) or UPPER jaw (if lower is hidden and upper is visible).
Blue (default): Occlusal contacts.
Green (default): Planar contacts.
Gradient (blue to red, default jet colormap): Shows distance to the maxilla if it's greater than the occlusal threshold but less than gradient_max_dist.
4. Before/After: Toggles the mandible display between its initial position ("Before" – as loaded) and the current, post-iteration position ("After"). Useful for comparison. The heatmap updates automatically on toggle.
5. Save ZIP: Saves the result. The current mandible position (even if you're viewing "Before," "After" will be saved) and the original maxilla (as it was in the window) are packed into a ZIP archive as STL files.
6. Lower/Upper Vis: Toggle buttons to hide/show the mandible and maxilla in the 3D window. Sometimes useful to hide one jaw to better see contacts on the other.
Current status:
It's starting to give plausible results. If I feed it a pair of scans that are slightly off, it often brings them to a state where contacts appear where you'd logically expect them.
Here's an example on real scans: The intraoral scans were initially shifted significantly to the side and upwards relative to each other. For validation, these models were 3D printed and mounted in an articulator using a physical bite registration. The program managed to align the digital scans to a position that matched (lets say on 70%) physically verified bite.


Download BiteGrinder from my cloud (168.57 MB)
Download here
(SHA:793c999a64a9d092b0e523fa0c89ce77502fed300782d6ff2cae76e8eaaca31d)
For your peace of mind, you can verify the file on VirusTotal:
VirusTotal Report
Download here
(SHA:793c999a64a9d092b0e523fa0c89ce77502fed300782d6ff2cae76e8eaaca31d)
For your peace of mind, you can verify the file on VirusTotal:
VirusTotal Report
I'd really appreciate it if you could:
- Test it out! Take your problematic scans, play with the settings. What do you get?
- Share results and settings! If you find a combination of parameters that works well for certain types of cases – please share!
- Suggest ideas! I'm sure there are plenty of smart people here who are better at math, CAD/CAM, Code, or just dentistry than I am. Maybe someone has thoughts on how to improve the algorithm? What other criteria for a "correct bite" could be added? How to deal with scan noise more effectively?
Thanks in advance, everyone!
p.s obviously, scans aren't perfect impressions. They can be inaccurate, have artifacts, be "noisy," and sometimes the model can be "warped" along the arch (especially full arches from an intraoral scanner). My code won't magically fix global problem. But I've tried to make the code "work around" and correct for small irregularities, "roughness," and minor discrepancies.
Last edited:

!
