# Project export: R3dPair

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: Cal Hacks 10.0
- Tagline: R3scue and r3pair failed prints
- Devpost: https://devpost.com/software/cv3dpair
- GitHub: https://github.com/yonx30/GCodeRepair
- Team: 1 GitHub contributor(s) — yonx30 (9 commits)

## Devpost submission (written by the team)

### Inspiration

I've faced numerous print failures (especially since I started with an Ender) over my time printing. A common one if when the printer just stops at a layer - due to power failure, running out of filament, or whatever else is making the printer cranky. Repairing these failed prints often requires manually splitting the CAD model in Solidworks, then slicing the remaing portions, printing them and gluing them back.

### What it does

This kit repairs failed FDM 3D print gcodes based on the layer it failed at, to enable direct printing starting from the failed layer. The effect is similar to power loss recovery, but for all kinds of failures involving stopping at a specific layer (and without the blobbing).

### How we built it

It first uses OpenCV enabled camera measurement of failed part, or physical measurement. Once a measurement is obtained, it can be sent to a separate Python script that modifies the GCode model directly to enable the printer to restart from that layer.

### Challenges we ran into

CV measurements aren't the most accurate Didn't have a physical printer to test/validate Limited time to do especially as 1 man team

### Accomplishments we're proud of

Managed to make something (somewhat) functional

### What we learned

A lot on OpenCV and CV detection/comparison methods Libraries for STL manipulation Some parts of photogrammetry (that wasn't implemented)

### What's next

Will want to build upon this, especially once I'm back at my 3D printer to test it in real life!

## README (from the GitHub repository)

# GCodeRepair
Script to repair GCode

# What it does
This kit repairs failed FDM 3D print gcodes based on the layer it failed at, to enable direct printing starting from the failed layer.

The effect is similar to power loss recovery, but for all kinds of failures involving stopping at a specific layer (and without the blobbing).

# How we built it
It first uses OpenCV enabled camera measurement of failed part, or physical measurement.

Once a measurement is obtained, it can be sent to a separate Python script that modifies the GCode model directly to enable the printer to restart from that layer.


## Detected evidence (automated analysis)

Indexed codebase: 7 recognized source files, 35 KB.
- Python (language) — detected in the code

## Codebase structure (from repository index)

### Files (8 of 8)

```
.gitignore
CVRepair/CVRepair.py
CVRepair/heightcv.py
CVRepair/stleditor.py
CVRepair/stlviewer.py
CVRepair/webcam.py
gcoderepair.py
README.md
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- Update README.md
- Update README.md
- Create README.md
- Final Commit for Calhacks
- Updated CV matching
- Added height scanning functionally with OpenCV
- Added .exe package for gcodepair.py
- Refactored search to use Numpy and Pandas instead of Python list
- Merge branch 'main' of https://github.com/yonx30/GCodeRepair
- Fixed filename keyword, removed print statements
- Merge pull request #1 from yonx30/master
- Initial Commit
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### gcoderepair.py

```python
import os
import math
import numpy as np
import pandas as pd

defaultFileDirectory = r"C:\Users\yonx3\Documents\Crossbow\GCodes"
defaultFileName = "CE3_3DBenchy.gcode"

startLayerSafetyMargin = 0 # Number of extra layers to start printing above last printed layer
lastInitGcode = "M420 S1" # Last non-movement GCode after homing

class gcodereader:

    def __init__(self, filepath):
        self.filepath = filepath
        self.lines = None
        self.layerCount = None
        self.lastLayer = None
        self.totalHeight = None
        self.failureLayer = None
       
        self.read()

    # Reads a GCode file into a numpy array
    def read(self):
        with open(self.filepath, "r") as file:
            lines = (line.strip() for line in file.readlines() if line.strip())
            self.lines = np.array(tuple(lines))
        self.get_layer_height()
        # self.get_last_layer()

    # Retrieves a value (eg. Layer Height) from the GCode, where targetStr is the GCode command or descriptor
    def get_gcode_val(self, targetStr:str):
        df = pd.Series(self.lines).str.contains(targetStr)
        idx = np.where(np.array(df) != 0)[0][0]
        line = self.lines[idx]
        linestr = line.strip(" ")
        idx = linestr.find(":")
        return float(linestr[idx+1:])  

    # Find the index of a the first instance of a specific GCode command 
    def find_gcode_idx(self, targetGCode:str, mode:str="find", mod=None):
        
        if not mod:
            array = self.lines
        else:
            array = self.lines[:mod][::-1]

        if mode.lower() == "find":
            boolArray = np.char.find(array, targetGCode, start=0, end=None)
            idx = np.where(np.array(boolArray) != -1)[0][0]    

        elif mode.lower() == "contains":
            df = pd.Series(array).str.contains(targetGCode)
            idx = np.where(np.array(df) != 0)[0][0]
        return idx
                                 
    # Gets the layer height of the part    
    def get_layer_height(self):
        layerHeight = self.get_gcode_val(targetStr=";Layer height")
        self.layerHeight = layerHeight
        print(f"Layer Height: {self.layerHeight}")

    # Gets the last layer of the complete    
    def get_last_layer(self):
        lastLayer = self.get_gcode_val(targetStr=";LAYER_COUNT:") - 1
        self.lastLayer = lastLayer
        print(f"Layer Count: {self.lastLayer}")

    # Gets the last layer printed of the part, based on the measured part height
    def get_failure_layer(self, zHeight:float or int):
        failureLayer = math.ceil(zHeight / self.layerHeight) 
        self.failureLayer = failureLayer
        print(f"Failure Layer: {self.failureLayer}")

    # Modifies the GCode to home, then start printing one layer after the last printed (failed) layer
    def modify_gcode(self):
        # startIdx is the numpy index of the last non movement GCode after initial homing
        startIdx = self.find_gcode_idx(lastInitGcode, "contains")

        # endIdx is the numpy index of the next layer to be printed, with failureLayer plus a specific safety margin (default 0)
        endIdx = self.find_gcode_idx(f";LAYER:{self.failureLayer + startLayerSafetyMargin}", "find")  

        # restartIdx is the numpy index of the last Z axis movement, to bring the Z axis up to the next layer to print after the
        # last failed layer
        restartIdx = self.find_gcode_idx("Z", "contains", mod=endIdx)
        restartIdx = endIdx - restartIdx - 1

        # Cut off the line represented by restartIdx to only include Z axis movements
        # Do this to avoid printhead crashing into incomplete print before rising to the correct Z height
        restartLine = self.lines[restartIdx].split(" ")
        newLine = []
        for command in restartLine:
            if not command.startswith("X") and not command.startswith("Y"):
                newLine.append(command)
        self.lines[restartIdx] = " ".join(newLine)

        # Combine the different GCode parts to remove the unneeded layers
        modifiedGCode = np.concatenate((self.lines[:startIdx], [self.lines[restartIdx]], ["M0 Please replace print bed then click to continue ; Stop for user input"], self.lines[restartIdx+1:]))

        # Save modifiedGCode as a new Gcode file
        modifiedFile = open(fileDirectory + fileName + " - Repaired.gcode", "w")
        for line in modifiedGCode:
            modifiedFile.write(f"{line}\n")
        
        print("GCode Modified!")
        

def main():
    while True:
        global filepath, fileDirectory, fileName
        fileDirectory = input("Please enter file directory: ") + "/"
        if fileDirectory == "/":
            fileDirectory = defaultFileDirectory + "/"
        fileName = input("Please enter file name: ")

        if not fileName:
            fileName = defaultFileName
        filepath = fileDirectory + fileName

        if not os.path.exists(filepath):
            print(f"Error, file at {filepath} not found. Please re-enter file directory and name!")
        else:
            break


    gcodeobj = gcodereader(filepath)

    while True:
        try:
            failurePartHeight = float(input("Enter part height: "))
        except ValueError or TypeError:
            continue
        else:
            break

    gcodeobj.get_failure_layer(failurePartHeight)
    gcodeobj.modify_gcode()


if __name__ == "__main__":
    print("Starting...")
    main()
    
    
```

### CVRepair/webcam.py

```python



"""
# # Opens the inbuilt camera of laptop to capture video. 
# cap = cv2.VideoCapture(0) 
# i = 0
fileDir = os. getcwd()
# print(f"{fileDir}/Webcam Images/Frame {str(i)}.jpg")

# while(cap.isOpened()): 
#     ret, frame = cap.read() 
      
#     # This condition prevents from infinite looping  
#     # incase video ends. 
#     if ret == False: 
#         break
      
#     # Save Frame by Frame into disk using imwrite method 
#     cv2.imwrite(f"{fileDir}/Webcam Images/Frame {str(i)}.jpg", frame) 
#     i += 1
  
# cap.release() 
# cv2.destroyAllWindows()
"""
import os
import cv2 
import numpy as np
import time
from mss import mss
# from PIL import Image

fileDir = os. getcwd()

class screenRecorder:
    def __init__(self) -> None:
        self.bounding_box = {'top': 300, 'left': 500, 'width': 600, 'height': 600}
        self.sct = mss()

    def screenshot(self):
        
        sct_img = self.sct.grab(self.bounding_box)
        #print(sct_img)
        npImg = np.array(sct_img)
        #print(1, npImg)
        return npImg


```

### CVRepair/stlviewer.py

```python
import vtkplotlib as vpl
from stl.mesh import Mesh
import stl
import numpy as np

path = r'C:\Users\yonx3\Documents\Crossbow\3DBenchySliced.stl'
savePath = r'C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\model6.jpg'

class stlMesh:
    def __init__(self, filepath):
        self.filepath = filepath
        self.createMesh(self.filepath)

    def createMesh(self, filepath):
        # Read the STL using numpy-stl
        self.mesh = Mesh.from_file(filename=filepath)

    # Plot the mesh
    def plotMesh(self, colour:str="red"):
        vpl.mesh_plot(self.mesh, color=colour, fig="gcf")
        
    def viewMesh(self, focus_coord=np.array([25,0,0]), camera_coord=np.array([25,-112.5,10])):
        vpl.view(focal_point=focus_coord, camera_position=camera_coord, fig="gcf")

    # Save the figure
    def saveFig(self, savefilepath:str, magnification=1):
        print(savefilepath)
        vpl.save_fig(path=savefilepath, magnification=magnification, fig="gcf")

    def find_x_dim(self):
        minx = maxx = None # = miny = maxy = minz = maxz = 
        for p in self.mesh.points:
            # p contains (x, y, z)
            if minx is None:
                minx = p[stl.Dimension.X]
                maxx = p[stl.Dimension.X]
                # miny = p[stl.Dimension.Y]
                # maxy = p[stl.Dimension.Y]
                # minz = p[stl.Dimension.Z]
                # maxz = p[stl.Dimension.Z]
            else:
                maxx = max(p[stl.Dimension.X], maxx)
                minx = min(p[stl.Dimension.X], minx)
                # maxy = max(p[stl.Dimension.Y], maxy)
                # miny = min(p[stl.Dimension.Y], miny)
                # maxz = max(p[stl.Dimension.Z], maxz)
                # minz = min(p[stl.Dimension.Z], minz)
            
        return abs(maxx - minx)

if __name__ == "__main__":
    newMesh = stlMesh(path)
    newMesh.plotMesh()
    newMesh.viewMesh()
    newMesh.saveFig(savePath, magnification=2)

# import vtkplotlib as vpl
# from stl.mesh import Mesh
# import numpy as np

# path = r'C:\Users\yonx3\Documents\Crossbow\3DBenchy.stl'
# print(path)
# savePath = r"C:\Users\yonx3\Documents\Crossbow\model2.stl"
# print(savePath)

# def plotVPL(filePath, savePath, colour:str="red", focus_coord=np.array([0,0,0]), camera_coord=np.array([112.5,-112.5,125]), magnification=1):
#     vplmesh = Mesh.from_file(filePath=filePath,)

#     # Plot the mesh
#     vpl.mesh_plot(vplmesh, color=colour, fig="gcf")
        
#     # Change the view
#     vpl.view(focal_point=focus_coord, camera_position=camera_coord, fig="gcf")

#     # Save the figure
#     vpl.save_fig(path=savePath, magnification=magnification, fig="gcf")

# if __name__ == "__main__":
#     plotVPL(path, savePath, "red", np.array([10,10,10]), magnification=1)
```

### CVRepair/stleditor.py

```python
import trimesh 
import pyglet # Needed for trimesh plot
import numpy as np
from PIL import Image
import io
import cv2 as cv
# import scipy # Needed for trimesh plot

path = r'C:\Users\yonx3\Documents\Crossbow\3DBenchySlice1.stl'
savePath1 = r'C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\model7.stl'
savePath2 = r'C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\model7.jpg'
savePath3 = r'C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\splitModel1.stl'

class stlMesh:
    def __init__(self, filepath):
        # trimesh.util.attach_to_log()

        self.filepath = filepath
        self.createMesh(self.filepath)


    def createMesh(self, filepath):
        # Read the STL using numpy-stl
        self.mesh = trimesh.load(filepath)
        

    # Plot the mesh
    def plotMesh(self, colourArray:list or tuple=[0,0,0,255]):
        self.mesh.visual.face_colors = colourArray
        self.mesh.show()
        
    # Save the figure
    def saveMesh(self, savefilepath:str, mesh=None):
        if not mesh:
            self.mesh.export(savefilepath)
        else:
            mesh.export(savefilepath)

    # Save the figure
    def saveSnapshot(self, savefilepath:str, mode:str):
        try:
            # data = self.mesh.scene.save_image(resolution=(1080,1080))
            window_conf = pyglet.gl.Config(double_buffer=True, depth_size=6)
            scene = trimesh.load(self.filepath, force='scene')
            # scene.DirectionalLight.color = [0,0,255,255]
            points = np.array([self.mesh.centroid]) # Point towards CG of mesh
            points[0, 2] = points[0, 2] - 10
            if mode == "B":
                rotation = np.array([[1,0,0,0],[0,0,-1,0],[0,1,0,0],[0,0,0,1]]) # x,y,z,magnification rotation matrix
            elif mode == "T":
                rotation = None
            scene.camera_transform = scene.camera.look_at(points=points, rotation=rotation, distance=max(self.mesh.extents)*2, center=None)
            
            data = scene.save_image(resolution=[320, 240], window_conf=window_conf)
        except:
            return
        image = np.array(Image.open(io.BytesIO(data))) 
        encode = cv.imencode('.jpg', image)
        decode = cv.imdecode(encode[1], cv.IMREAD_GRAYSCALE)
        cv.imwrite(savefilepath,decode)

    def find_x_dim(self):
        try:
            meshExtents = self.mesh.extents
        # print(meshExtents)
            return abs(meshExtents[0])
        except:
            return 0
    
    def find_z_dim(self):
        try:
            meshExtents = self.mesh.extents
            print(meshExtents)
            return abs(meshExtents[1])
        except:
            return 0
    
    def section(self, sectionHeight:int or float):
        splitSTL = trimesh.intersections.slice_mesh_plane(self.mesh, (0,0,-1), (0,0,sectionHeight))
        return splitSTL


if __name__ == "__main__":
    path = r"C:\Users\yonx3\Documents\Crossbow\Random\catstretch_voronoi updated.stl"
    savePath2 = r"C:\Users\yonx3\Documents\Crossbow\Random\catstretch_voronoi slice1.stl"
    savePath3 = r"C:\Users\yonx3\Documents\Crossbow\Random\catstretch_voronoi slice2.stl"
    newMesh = stlMesh(path)
    #newMesh.plotMesh([255,0,0,255])
    #newMesh.saveMesh(savePath1)
    #newMesh.saveSnapshot(savePath2)
    splitMesh = newMesh.section(10)
    splitMesh.export(savePath2)
    splitMesh = newMesh.section(30)
    splitMesh.export(savePath3)
    #splitMesh.show()

```

### CVRepair/CVRepair.py

```python
import webcam as wb
from heightcv import cvImage, compare_hu_moments
import stleditor 
#import gcoderepair 
import time
import sys, asyncio
import numpy as np
import trimesh
# import os

sys.path.insert(0, r'C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks')

referenceModelPath = r'C:\Users\yonx3\Documents\Crossbow\3DBenchy.stl'
# referenceModelPath = r"C:\Users\yonx3\Documents\Crossbow\Random\catstretch_voronoi updated.stl"

savePath = r'C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\referenceSlice.jpg'
meshPath = r'C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\newMesh.stl'

mode = "B"

def init():
    global screenCam, stlImage, x_dim, newMesh

    fileDirectory = input("Please enter file directory: ") + "/"
    fileName = input("Please enter file name: ")
    if not fileName and fileDirectory == "/":
        filepath = referenceModelPath
    else:
      filepath = fileDirectory + fileName
    print(filepath)
        
    screenCam = wb.screenRecorder()
    newMesh = stleditor.stlMesh(filepath)
    # newMesh.plotMesh()
    newMesh.saveSnapshot(savePath, mode)
    x_dim = newMesh.find_x_dim()
    print(f"X dimension = {x_dim}")
    args = {"image":savePath}
    stlImage = cvImage(args)


async def get_new_mesh(mesh, savePath):
    global x_dim
    mesh.saveSnapshot(savePath, mode)
    x_dim = mesh.find_x_dim()
    # print(f"X dimension = {x_dim}")
    args = {"image":savePath}
    return cvImage(args)

async def get_new_image(mesh, newPartHeight:int or float, meshPath:str):
    splitRes = mesh.section(newPartHeight)
    trimesh.exchange.export.export_mesh(splitRes, meshPath, 'stl')
    splitMesh = stleditor.stlMesh(meshPath)
    # newMesh.plotMesh()
    newImage = await get_new_mesh(splitMesh, savePath)
    return newImage


async def main():
    global newMesh, stlImage, x_dim, oldPartHeight, newPartHeight
    oldPartHeight, newPartHeight = 0, 0
    decrementFlag = False
    highestMatch = {"HuDiff":5, "Height":0}
    bestPartHeights = np.array([],dtype=float)

    while True:
        image = screenCam.screenshot()
        # print(image)
        try:
            np.average(stlImage.lgHuMoments)
        except:
            pass
        screencapImage = cvImage(image=image)
        # print(screencapImage.lgHuMoments, stlImage.lgHuMoments)
        
        huDiff = compare_hu_moments(screencapImage, stlImage)
        if huDiff:
            imageMatch = 100 * (1 -  abs(huDiff / 10))
        else:
            imageMatch = 0
        screencapImage.write_text(300, 400, f"Match = {imageMatch}%")
        

        if mode == 'B':
            if imageMatch > 90:
                newPartHeight = screencapImage.draw_bounding_box(width=x_dim,visibility=True)
            else:
                newPartHeight = screencapImage.draw_bounding_box(width=x_dim,visibility=False)
        
            # print(newPartHeight)
            if newPartHeight:
                heightDiff = oldPartHeight - newPartHeight  
                # print(heightDiff) 
                if decrementFlag: 
                    if heightDiff > 0:
                        decrementFlag = True

                    if (abs(heightDiff) > 0.1 and heightDiff < 1 and imageMatch > 98) or (abs(heightDiff) > 0.1 and heightDiff < 0 and imageMatch > 96):
                                                
                            # Get new sliced STL based on height
                            print(f"Slicing at height: {newPartHeight}")
                            stlImage = await get_new_image(newMesh, newPartHeight, meshPath)
                            res = compare_hu_moments(screencapImage, stlImage)
                            if res == None or huDiff > res:
                                newMesh = stleditor.stlMesh(meshPath)

                else:
                    if heightDiff < 0:
                        decrementFlag = True
                    print(abs(heightDiff), imageMatch)
                    if abs(heightDiff) > 0.3 and imageMatch > 86:
                        # Get new sliced STL based on height
                        print(f"Slicing at height: {newPartHeight}")
                        if abs(heightDiff) > 5:
                            splitRes = newMesh.section(oldPartHeight - 5)
                        else:
                            splitRes = newMesh.section(newPartHeight)
                        trimesh.exchange.export.export_mesh(splitRes, meshPath, 'stl')
                        newMesh = stleditor.stlMesh(meshPath)
                        # newMesh.plotMesh()
                        stlImage = await get_new_mesh(newMesh, savePath)
                
                if huDiff and huDiff < highestMatch["HuDiff"]:
                    highestMatch["HuDiff"] = huDiff
                    highestMatch["Height"] = newPartHeight
                    np.append(bestPartHeights, newPartHeight)

                    print("New best part height: %s"%(highestMatch["Height"]))

        elif mode == 'T': 
            partZ = newMesh.find_z_dim()
            
            topImage = await get_new_image(newMesh, partZ + 15, meshPath)
            bottomImage = await get_new_image(newMesh, partZ - 15, meshPath)
            res1 = compare_hu_moments(screencapImage, topImage)
            res2 = compare_hu_moments(screencapImage, bottomImage)
            if res1 == None or res2 == None:
                continue
            imageMatch1 = 100 * (1 -  abs(res1 / 10))
            imageMatch2 = 100 * (1 -  abs(res2 / 10))
            print(f"Part height: {partZ}, topHu = {res1}, bottomHu = {res2}")
            if imageMatch < 99 and imageMatch2 < 99:
                if (abs(res1) - abs(res2)) < 0.001:
                    newZ = partZ
                elif abs(res1) > abs(res2) or (abs(res1) - abs(res2)) < 0.1:
                    newZ = partZ - 15
                else:
                    newZ = partZ + 10
                print(newZ)
                splitRes = newMesh.section(newZ)
                trimesh.ex
[truncated — 438 more characters]
```

### CVRepair/heightcv.py

```python
import cv2 
# import os 
import numpy as np
# import time
from scipy.spatial import distance as dist
from imutils import perspective
from imutils import contours
import imutils
from math import log10 as lg

dir = r"C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images/"
name = "model2.jpg"

# dir = r"C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images/"
# name = "model.jpg"


def midpoint(ptA, ptB):
	return ((ptA[0] + ptB[0]) * 0.5, (ptA[1] + ptB[1]) * 0.5)

# construct the argument parse and parse the arguments
# ap = argparse.ArgumentParser()
# ap.add_argument("-i", "--image", required=True,
# 	help="path to the input image")
# ap.add_argument("-w", "--width", type=float, required=True,
# 	help="width of the left-most object in the image (in inches)")
# args = vars(ap.parse_args())

# args = {"image":r"C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\OpenCVCans.jpg", "width":10}
# args = {"image":r"C:\Users\yonx3\Documents\NUS Y2S1\NOC\Misc\Calhacks\Webcam Images\6SIR Logo.jpg", "width":10}
# args = {"image":dir+name, "width":10}

# cap = cv2.VideoCapture(0) 
# while(cap.isOpened()): 
#     ret, image = cap.read() 
#     if ret == False: 
#         break

class cvImage:
    def __init__(self, args:dict=None, image=None) -> None:
        if type(image) == np.ndarray:
            self.image = image
        elif args:
            self.image = cv2.imread(args["image"])
        self.lgHuMoments = None
        self.cvImage = None
        self.contours = None
        if self.find_countours():
            self.draw_contours()

    def find_countours(self):
        gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY) 
        gray = cv2.GaussianBlur(gray, (9, 9), 0) # Blurring removes noise, (7,7) is standard deviation in x/y directions for blur   

        # perform edge detection, then perform a dilation + erosion to
        # close gaps in between object edges
        kernel = np.ones((2,2),np.uint8)
        edged = cv2.Canny(gray, 93, 100, apertureSize=3)
        edged = cv2.dilate(edged, kernel, iterations=2) # Dilate expands the width of (contour) lines and features
        edged = cv2.erode(edged, kernel, iterations=2) # Erode is the opposite of dilate

        # find contours in the edge map
        self.contours = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # Draws contours - 1st arg is image, 2nd is contour retrieval mode and 3rd is contour approx method
        self.contours = imutils.grab_contours(self.contours)
        try:
            (self.contours, _) = contours.sort_contours(self.contours)
        except:
            return False
        else:
            self.cvImage = self.image.copy()
            return True

    def draw_contours(self):
        # loop over the contours individually
        largestContour = self.contours[0]
        for contour in self.contours:
            # Compute the contour area, reject those below a certain size
            if cv2.contourArea(contour) < 2500:
                continue

            if cv2.contourArea(contour) > cv2.contourArea(largestContour):
                largestContour = contour

            # Fits straight line contours to the given contour approximately, with the straight line lengths being epsilon and the Bool (True)
            # referring to whether the approxPolyDP should be closed
            epsilon = 0.0001*cv2.arcLength(contour,True)
            contourApprox = cv2.approxPolyDP(contour,epsilon,True)

            # Construct a numpy array with the contour coordinates
            contourApprox = np.array(contourApprox, dtype="int")

            # args = (image, contours to be passed, contours to draw [-1 means all], RGB colour of contours, thickness)
            cv2.drawContours(self.cvImage, [contourApprox], -1, (255, 0, 0), 2) 
            # huMoments = cv2.HuMoments(cv2.moments(image))

        # Only get HuMoment of largest centroid
        huMoments = cv2.HuMoments(cv2.moments(largestContour))
        try:
            self.lgHuMoments = np.array([-lg(abs(huMoment[0])) for huMoment in huMoments], dtype=float)
        except:
            pass
        
        else:
            try:
                # Gets the centroid, then write the lgHuMoment values at the centroid
                moment = cv2.moments(contour)
                cX = int(moment["m10"] / moment["m00"])
                cY = int(moment["m01"] / moment["m00"])
                cv2.putText(self.cvImage, "{:.1f}".format(np.average(self.lgHuMoments)),
                (int(cX), int(cY)), cv2.FONT_HERSHEY_SIMPLEX,
                0.65, (0, 0, 0), 2)
            except:
                pass
    
    def draw_bounding_box(self, width:int or float, visibility:bool=True):
        if visibility:
            colourPalette = (0,255,0)
        else:
            colourPalette = (0,0,255)
        pixelsPerMetric = None
        for contour in self.contours:
            # Compute the contour area, reject those below a certain size
            if cv2.contourArea(contour) < 2500:
                continue        
            # compute the rotated bounding box of the contour
            box = cv2.minAreaRect(contour)
            box = cv2.cv.BoxPoints(box) if imutils.is_cv2() else cv2.boxPoints(box)
            box = np.array(box, dtype="int")

            # order the points in the contour such that they appear
            # in top-left, top-right, bottom-right, and bottom-left
            # order, then draw the outline of the rotated bounding
            # box
            box = perspective.order_points(box)
            cv2.drawContours(self.cvImage, [box.astype("int")], -1, colourPalette, 3)

            
            # loop over the original points and draw them
            for (x, y) in box:
                cv2.circle(self.cvImage, (int(x), int(y)), 5, (0, 0, 255), -1)
            
            # unpack the ordered bounding box, then compute the midpoint
            # between the top-left and top-right coordina
[truncated — 10070 more characters]
```