QR-Codengrave

Projekt-Steckbrief

  • Schwierigkeitsgrad: Schwer 4/5
  • Kosten: 0€
  • Zeitaufwand: ~50h

Die Idee

Ein Freund bat mich, einen Bierdeckel für ihn anzufertigen. Ein eigenes Logo, ein paar Buchstaben. Nichts allzu Schweres. Auf der Rückseite wollte er einen QR-Code eingravieren lassen, der einen Link zu seiner Website enthält.

Ich habe also einen QR-Code mit einem Standard-Webtool erstellt und als .svg exportiert. Leider müsste ich in meinem CAM jedes einzelne Pixel anklicken und eine Carve-Action für dieses Pixel erstellen, was einige Minuten meiner Zeit für Auswahl-Klick-und-Parameter-Wähl-Prozeduren in Anspruch nehmen würde. Außerdem müsste ich einen Gravurstichel mit tiefenvariabler Gravur verwenden, um ein quadratisches Pixel zu erhalten, was viel mehr Bearbeitungszeit erfordert als eine runde Darstellung.

Bild: QR-Code SVG in meinem CAM

An diesem Punkt beschloss ich, viele, viele weitere Stunden meiner Zeit auf dieses Problem zu werfen und eine allgemeine Lösung für QR-Code-Gravuren zu entwickeln - eine Software namens QR-codengrave.

Erste Schritte

Spezifikation

Das, was ich mit dem Tool erreichen wollte:

Muss es können:

  1. Texteingabe verarbeiten und in einen QR-Code konvertieren. Der Benutzer muss keine zusätzliche Konfiguration vornehmen.
  2. Bietet die Option zur Verwaltung verschiedener CNC-Werkzeuge.
  3. Ermöglicht Eingabe von Gravurparametern: Gravurtiefe, Eilgangbewegung im Job, Überflughöhe
  4. Ermöglicht Eingaben für benutzerdefinierte XY0-Offsets für Werkstücke
  5. Vektorisiert die Pixelausgabe des QR-Codes
  6. Kann Pfade aus den Vektoren erstellen lassen, beginnt der Einfachheit halber mit einer Spirale nach innen
  7. Übergabe der Pixellinien an ein Modul, das die Parameter des ausgewählten Werkzeugs verwendet, um Maschinenbahnen zu erstellen
  8. Maschinenbahnen in G-Code konvertieren, der später von meiner Maschine interpretiert werden kann.
  9. Gründliche Überprüfung der Algorithmen mit automatisierten Unit-Tests (bitte keine Abstürze in der realen Welt)
  10. Bietet eine grafische Benutzeroberfläche, um die Parametereingabe so einfach wie möglich zu machen.

Optional

  1. Eine Continuous-Integration-Pipeline sowohl für meine IDE als auch für Github-Aktionen erstellen
  2. Eine Continuous-Deploy-Pipeline für meine IDE (PyInstaller) und Github-Aktionen (Artefakte, Releases) erstellen
  3. Integrationstests für die GUI hinzufügen
  4. Optimieren des Algorithmus zur Pfaderzeugung, da die “nach innen gerichtete Spirale” eine hohe Fertigungsqualität hat und schön anzusehen ist, aber nicht sehr effizient mit der Zeit umgeht.

Unbekannte

Zu Beginn dieses Projekts hatte ich einige offene Fragen und Risiken, die die Nutzbarkeit des Programms hätten einschränken oder sogar das gesamte Projekt undurchführbar machen können. Sie haben sich alle als falsch herausgestellt (zum Glück), aber dennoch sind hier die Risiken, die meine ganze Arbeit unbrauchbar hätten machen können:

  • Kameras lesen keine runden Punkt-als-Pixel-QR-Codes.
  • Der Kontrast, den meine Graviermaterialien bieten, ist zu gering, so dass das Lesen der Codes schlecht oder gar nicht funktioniert.
  • Die Bearbeitungszeit könnte sehr hoch sein, was das Projekt unwirtschaftlich machen würde.
  • Beschränkungen der Programmierwerkzeuge oder meiner Programmierfähigkeiten könnten die Entwicklungszeit so verlängern, dass es sich nicht lohnt, weitere Anstrengungen zu unternehmen.

Daher beschloss ich, Unit-Tests zusammen mit dem Code zu schreiben, um ein schnelles Feedback darüber zu erhalten, ob mein Code das leisten kann, was er soll. Außerdem habe ich die Funktionalität des Programms auf eine Gravurstrategie beschränkt und die GUI so gestaltet, dass sie nur eine Vorschau des QR-Codes anzeigt, nicht aber XY-Offsets oder anderen Schnickschnack.

Programmiersprache

Ich nahm einige Blätter Papier und notierte mir das Wesentliche.

Bild: Softwareplanung auf Papier

Ein Modul soll sich um die Erstellung eines QR-Codes aus einer Texteingabe kümmern, ein anderes soll Vektoren und Pfade erzeugen, um diesen QR-Code zu gravieren, und eine GUI in der Mitte soll die Texteingabe, eine Bearbeitungsvorschau und Eingabemöglichkeiten für Fräswerkzeuge, Gravurparameter und Werkstückversätze enthalten.

Ich hatte ein paar Programmiersprachen zur Auswahl, die ich gut genug “spreche”:

  • C++ mit QT-Framework
  • Java
  • Python mit dem Tkinter-Framework

Ich entschied mich für die - meiner Meinung nach - einfachste Lösung, um so schnell wie möglich einen lauffähigen Prototyp zu haben: Python mit Tkinter. Ich kenne Tkinter von einem meiner früheren Projekte und obwohl es seine Einschränkungen hat, ist es immer noch leistungsstark genug und schnell zu verwenden.

Konzeptphase

Nach etwas Recherche fand ich eine ziemlich gute Open-Source-QR-Code-Bibliothek im Internet, die auch in Python verfügbar ist. Auf der Grundlage ihrer Ausgaben habe ich einen Vektorisierungsalgorithmus erstellt, der die QR-Code-Bibliothek abfragen kann, um die Werte der Pixel des QR-Codes in einer Scanzeile zurückzugeben:

    def _qr_bitstream_from_line(self, line):
        """Creates a bitstream from an input line of a QR-code data representation
        :param line: a QR-code data representation (line of bits within the QR-code)
        :returns bitstream: returns an array of bits reflecting the QR code's state at the respective
        point of the line."""
        bitstream = []
        if line.get_direction() == Direction.RIGHT:
            for x in range(line.get_p_start().x, line.get_p_end().x + 1):
                bitstream.append(self._qr.get_module(x, abs(line.get_p_start().y)))
        if line.get_direction() == Direction.LEFT:
            for x in range(line.get_p_start().x, line.get_p_end().x - 1, -1):
                bitstream.append(self._qr.get_module(x, abs(line.get_p_start().y)))
        if line.get_direction() == Direction.UP:
            for y in range(line.get_p_start().y, line.get_p_end().y + 1):
                bitstream.append(self._qr.get_module(line.get_p_start().x, abs(y)))
        if line.get_direction() == Direction.DOWN:
            for y in range(line.get_p_start().y, line.get_p_end().y - 1, -1):
                bitstream.append(self._qr.get_module(line.get_p_start().x, abs(y)))
        return bitstream

Dann musste ich aus diesen Linien Vektoren erstellen, so dass aufeinanderfolgende Pixel derselben Farbe zu einer Linie zusammengefügt werden konnten, die viel schneller herzustellen ist als einzelne Punkte:

    def _vectorize_bitstream(self, bitstream):
        """Creates a QrLineData object from an input bitstream
        :param bitstream: an array of bits = a line of the QR-code
        :returns line_vector: A list of QrLineData objects"""
        line_vector = []
        data = QrLineData(bitstream[0], True)
        for bit in range(1, len(bitstream)):
            if bitstream[bit] == data.get_state():
                data.add_length()
            else:
                line_vector.append(data)
                data = QrLineData(bitstream[bit])
        if not data.get_state():
            data.finalize()
        line_vector.append(data)

        return line_vector

Diese beiden Methoden würden nun von einer übergeordneten Methode aufgerufen werden, die einen spiralförmigen Pfad durch den QR-Code kennt. Meine erste GUI hatte nur drei Elemente: Ein Eingabefeld für den Text, der in einen QR-Code umgewandelt werden sollte, eine Schaltfläche, um den Algorithmus auszuführen, und eine Zeichenfläche, auf der der Gravurpfad des Algorithmus ausgegeben werden sollte.

Ich das Modul Turtle verwendet, um den vektorisierten QR-Code-Pfad zu zeichnen, ein Tool, das für Schülerinnen und Schüler geschrieben wurde, die sich in Programmiersprachen wie Python einarbeiten. Es ist zwar langsam, aber einfach einzurichten, so dass mir das für den Anfang genügte. Ich habe für’s Erste hart kodiert, um Zeit zu sparen, und meine Schleifen sind eher C-esk als pythonisch, aber egal.

        for vect in self._spiral_path[i].get_z_vector():
            if self._stop_draw:
                break
            length = vect.get_length()
            if vect.get_state():
                self.turtle.down()
                self.turtle.forward(self.pen_size * length)
                self.turtle.up()
            else:
                self.turtle.forward(self.pen_size * length)
        self.turtle.right(90)

Dieses sehr einfache Konzept sah für mich vielversprechend aus, aber ich musste ein paar Stunden daran feilen, weil meine anfängliche Ausgabe wie folgt aussah:

QR-codengrave GUI test

Nach einer Weile konnte ich jedoch zuverlässig einen (21x21 Pixel) QR-Code auf den Bildschirm zeichnen und die Kamera meines Smartphones verwenden, um den Link zur Webseite zu erhalten. 🥳 Yeah!

QR-codengrave Vektorerzeugung funktioniert

Unit-Tests

Die “Plattform”-Teile - Klassen vectorize_qr und machinify_vector - enthalten beide eine Menge überprüfbarer Berechnungen und Geschäftslogik. Deshalb sind meine Tests mit Pythons Standardmodul unittest schnell und einfach zu schreiben. In diesem Beispiel überprüfe ich, ob die von mir erstellte Linienklasse eine Länge (n+1 Stil) korrekt berechnet:

class TestLine(unittest.TestCase):

    def test_line_x_calculates_valid_length(self):
        line = Line(Point(0, 0), Point(5, 0))
        self.assertEqual(6, line.get_abs_length())

Aber es wurde schnell komplexer, als ich testen wollte, ob die Ausgabe der QR-Code-Generatorbibliothek in meinem Programm korrekt verarbeitet wird. Dazu musste ich die Bibliothek in meine zu testende Klasse einbinden, damit ich eine Mock-Implementierung einschleusen konnte, die dann im Test abgefragt wird.

Dieses Muster der Abhängigkeitsinjektion ist der Vorgehensweise in C++ sehr ähnlich, wo ich viel mehr Erfahrung habe (siehe ein Projekt hier oder ein anderes hier, zum Beispiel), nur dass in Python viel weniger Boilerplate-Code nötig ist, um dorthin zu gelangen. Ein paar Suchvorgänge im Internet und schon war der folgende Testfall geschrieben:

    def test_bitstream_qr_input_loop_xpos_correct(self):
        mock_qr = QrCode.encode_text("schallbert.de", QrCode.Ecc.MEDIUM)
        mock_qr.get_module = MagicMock()
        cam = VectorizeQr(mock_qr, 1)
        cam._qr_bitstream_from_line(self.test_line_x)
        mock_qr.get_module.assert_called_with(20, 0)

Ich injiziere die “echte” QrCode-Bibliothek, aber mit einer Mock-Methode, die für mich von Interesse ist, get_module(). In dem Test stelle ich sicher, dass die Methode mit den erwarteten Parametern aus meiner zu testenden Klasse aufgerufen wird.

Mit solchen Tests kann ich sichergehen, dass die Bausteine meines Algorithmus wie vorgesehen funktionieren und dass Regressionen schnell gefunden werden können.

Der lange Weg zum Produkt mit Minimalanforderungen

Beflügelt von diesem schnellen Erfolg, ging ich aufs Ganze. Vor der Implementierung des G-Code-Generators musste ich einen Werkzeugselektor entwerfen, da ich die Werkzeugeigenschaften zur Verfügung haben musste, bevor ich die Abmessungen und die Werkzeuggeschwindigkeit / den Vorschub für die QR-Code-Gravur einstellen konnte.

Nach einigen erfolglosen Versuchen im Hauptfenster entschied ich, dass das Werkzeugkonfigurationsfenster stattdessen ein Dialog sein sollte (den Tkinter Toplevel nennt), so dass die Werkzeugparameter eingegeben und nach Abschluss an die Haupt-GUI zurückgeschickt werden können. Ich habe den Dialog modal gemacht mit

    self._dialog.grab_set()

So hat er immer den Fokus, damit nicht mehrere Instanzen dieses Fensters gleichzeitig vorhanden sein können. Ich habe die konfigurierten Werkzeuge in einer nummerierten Liste gespeichert, so dass die Anwendung, sobald sie gefüttert wurde, die Liste zur Verfügung stellt, damit der Benutzer das gewünschte Werkzeug für den Job aus einem Dropdown-Menü auswählen kann.

Bild: QR-codengrave's tool configuration dialog

In diesem Schritt musste ich zwei Dinge implementieren:

  1. Überprüfungen für Wertetyp und -bereich
  2. Ein Persistenzmodul, so dass die eingegebenen Tools einen Neustart der Anwendung überleben würden.

Überprüfungen von Wertetyp und -bereich

Dies könnte mit einem Ereignis-Auslöser innerhalb des Eingabefeld-Widgets wie folgt erreicht werden:

    reg = config_tool_frame.register(validate_number)
    tool_nr_entry.config(validate="key", validatecommand=(reg, '%P'))

Anschließend wird der eingegebene Wert an einen einfachen Validator übergeben, der lediglich versucht, den Eingabewert in einen Float-Wert umzuwandeln und False zurückgibt, wenn dies einen Fehler erzeugt,

def validate_number(entry):
    """Helper function that is used in validators to check
    that the entered keystroke is a number"""
    if entry == '':
        return True
    try:
        float(entry)
        return True
    except ValueError:
        return False

Eine Prüfung ob der Wert plausibel ist erfolgt erst, wenn die Schaltfläche “OK” gedrückt wird, um die eingegebenen Daten an die Anwendung weiterzuleiten. Bei Überschreitung des Bereichs würde ein Warn-Popup (Modul tkinter.messagebox) erscheinen, das auf das mögliche Problem hinweist.

Persistenz

Das Persistenzmodul war etwas schwieriger zu implementieren. Ich entschied mich dafür, ein Element zu erstellen, das C++-Kenner eine statische Klasse nennen würden, d.h. eine Klasse, die nicht instanziiert werden muss, um eine ihrer Methoden aufzurufen. Diese Klasse hat nur zwei Methoden namens load und save, die das tun, was ihr Name andeutet, indem sie Pythons pickle Modul zur Serialisierung von Objekten verwenden, die ich speichern wollte. In Python habe ich den Dekorator @classmethod verwendet, um die statische Natur dieser Klasse zu kennzeichnen.

So einfach kann die Serialisierung von Daten mit den richtigen Werkzeugen werden:

    with open(app_persistence_path, 'wb') as file:
        pickle.dump([cls._tool_list,
                        cls._z_params,
                        cls._xy0],
                    file, protocol=2)

Als dies zuverlässig funktionierte, fügte ich ein Widget zur Konfiguration der Gravurparameter hinzu. Von Nachteil bei statischen Methoden ist, dass sie nur schlecht unit-testbar sind, weil Dependency Injection hier nicht funktioniert; sie sind ja instanzlos. Es bliebe mir nichts anderes üblich als “Fakes” zu schreiben, die im Testfalle die echte Speicher- bzw. Ladefunktion ersetzt.

Gravur-Parameter

Bild: QR-codengrave's engrave parameter configuration dialog Um den G-Code aus meinen QR-Code-Daten zu generieren, benötigte ich nicht nur Werkzeug- und Geschwindigkeits-/Vorschubdaten, sondern ich musste auch wissen, wie tief ich gravieren wollte, wie weit das Werkzeug bei Eilgängen über der Werkstückoberfläche schweben sollte und welche Sicherheitshöhe die CNC für die Rückkehr zum Ursprung oder zur Ausgangsposition verwenden sollte.

Es wird auf ähnliche Weise wie das Dialogfeld “Werkzeugkonfiguration” erstellt und verwendet dieselben Mechanismen zur Überprüfung von Typ und Bereich. Der einzige Unterschied besteht darin, dass er nicht über einen Klick auf eine Schaltfläche, sondern direkt über das zugehörige Textfeld gestartet wird, das den aktuellen Wert der Gravurparameter im Hauptfenster anzeigt.

Werkstück XY Nullpunktverschiebung

Bild: QR-codengrave's Werkstückversatz-Konfigurationsdialog Der Konfigurationsdialog für den Werkstückversatz enthält einige Radiobuttons zur Auswahl der gängigsten Werkstückursprünge und eine benutzerdefinierte Option, mit der der Benutzer Werte für den X- und Y-Achsennullpunkt des Werkstücks eingeben kann. Eine Funktion im Hintergrund berechnet den erforderlichen Versatz relativ zum Startpunkt der Gravierspirale.

Normalerweise würde ich nicht wollen, dass die grafische Benutzeroberfläche Berechnungen durchführt oder viel Verzweigungslogik verwendet, da dies schwieriger zu verifizieren ist, weil die Test Fixtures damit größer und schwieriger zu warten werden. In diesem Fall habe ich mich jedoch entschieden, die Offset-Berechnung mit Hilfe einer Wertetabelle innerhalb des GUI-Moduls durchzuführen, um zu vermeiden, dass alle GUI-Module zur Plattform und zurück kommunizieren müssen (und das durch 4 Klassen hindurch).

    def _get_xy_offset(self, offset):
        """calculates an XY coordinate offset from a preset point, taking the selected tool diameter into account.
        :param offset the selected offset from the above enum class
        :returns a XY Point where XY0 is assumed for the engraving."""
        qr = self._qr_dimension
        d = self._tool_diameter
        offsets = {Offset.CENTER: Point((d - qr) / 2, (qr - d) / 2),
                   Offset.TOPLEFT: Point(d / 2, -d / 2),
                   Offset.TOPRIGHT: Point(d / 2 - qr, -d / 2),
                   Offset.BOTTOMLEFT: Point(d / 2, qr - d / 2),
                   Offset.BOTTOMRIGHT: Point(d / 2 - qr, qr - d / 2)
                   }

        if offset in offsets:
            return offsets[offset]

Erstes Release V1.0

Die erste Version ist minimalistisch. Sie erfüllt ihren Zweck, hat aber noch einige Fehler (z.B. eine Fehlermeldung, wenn kein Werkzeug in der Auswahlliste ausgewählt ist) und Unzulänglichkeiten (z.B. ist das Zeichnen eines QR-Codes sehr langsam, beim erneuten Zeichnen kippt der QR-Code ein wenig usw.), aber sie sollte einfach funktionieren und dem Benutzer helfen, die schlimmsten Fehler auf seinem Weg zu vermeiden.

Bild: Hauptbildschirm von QR-codengrave

Ich habe die Ausführung auf meiner CNC-Maschine getestet und zumindest mein Handy ist in der Lage, zu einer Website zu navigieren, indem es einfach die Kamera auf das Werkstück hält 📱.

Automatisierte Integrationstests für die GUI: Frustrierende Arbeit

Als meine erste Version herauskam, wollte ich einige Tests für die grafische Benutzeroberfläche hinzufügen. Da ich es nicht geschafft habe, dort absolut keine Geschäftslogik zu haben, wollte ich zumindest Bereichsprüfungen sowie die Kommunikation mit der Plattform überprüfen, um sicherzustellen, dass alles wie vorgesehen funktioniert.

Aufgehängt in der Messagebox

Zu diesem Zweck schrieb ich einige Tests, die auf meinem lokalen Rechner liefen, mit dem Nachteil, dass, wenn der Test auf einen Warn- oder Fehlerdialog messagebox.showinfo / messagebox.showerror stieß, eine manuelle Benutzerinteraktion unbedingt erforderlich war, damit das Fenster geschlossen und der Test fortgesetzt werden konnte.

Ich habe keine einfache Möglichkeit gefunden, dieses Problem zu umgehen, da die Messagebox als unabhängiges Widget agiert, das nicht einfach manipuliert werden kann. Auch meine Versuche, einen Tastendruck als Seiteneffekt aus dem Test heraus auszulösen, waren nicht von Erfolg gekrönt (und wären am Ende wohl auch keine saubere Lösung gewesen).

Meine Lösung nach ein paar Stunden gemurmelten Fluchens ist, dass die Messagebox-Widgets in die GUI-Klassen injiziert werden, die sie verwenden:

@patch('src.gui.gui_tool_manage.GuiToolManager')
    def setUp(self, mock_guitoolmanager):
        tk.Tk()  # required to have tk variables properly instantiated
        self.mock_guitoolmanager = mock_guitoolmanager
        self.mock_msg = MsgBox()
        self.mock_msg.showinfo = MagicMock()
        Persistence.set_mock_msgbox(self.mock_msg)
        self.config_tool = GuiConfigureTool(self.mock_guitoolmanager, self.mock_msg, {'padx': 5, 'pady': 5})

Auf diese Weise kann ich mock_msg in die Gui-Klasse werfen, so dass nicht die echte showinfo genommen wird, sondern das Mock weiß, ob er wie vorgesehen aufgerufen wurde. Ich könnte ihr zur Testvorrichtung hinzufügen, so dass mein Testcode selbst DRY bleiben könnte.

Es fügt dem Konstruktor mehr Argumente hinzu und delegiert die Instanziierung an eine Klasse höherer Ebene, aber die Verwendung innerhalb der zu testenden Klasse bleibt trivial. Im Einsatz unter Realbedingungen agiert die Schnittstelle wie ein Proxy, der dann die echte showinfo-Methode des Messagebox Objektes aufruft.

Probleme mit statischen Klassen

Da die Persistenzklasse, die ich oben erwähnt habe, statisch ist, d.h. keine Instanziierung erfordert, hatte ich das nächste Problem von Anfang an in meinen Code eingebaut: Ein Integrationstest würde nun versuchen, in eine tatsächliche “persistence.dat”-Datei zu schreiben, eine Abhängigkeit, die ich nicht haben wollte, insbesondere nicht in der kontinuierlichen Integration (CI) - denn dort würde das Programm die Rechte für das Schreiben auf Festplatte nicht besitzen.

Also habe ich Persistence in die Haupt-GUI-Datei verschoben, die nicht getestet werden würde, und folgte damit Steven Andersons großartigem Blogbeitrag. Die Haupt-GUI sollte ohnehin nur deklarativen Code enthalten, und die geladenen Daten werden dann in die Klassen kaskadiert, die sie verwenden, anstatt in die Persistenzklasse, die die eigentliche Lade-/Speicheroperation durchführt.

Zukünftige Versionen

Folgendes ist für spätere Releases geplant:

  • Die Zeichnungsgeschwindigkeit des QR-Codes muss erhöht werden
  • Grafikfehler (Skalierung, Kippen) und unscharfe Ränder des Zeichenbildschirms sollen behoben werden
  • CNC-Bahnen sollen optimiert werden, um die Fertigungszeit zu verringern
  • Fehlerbehebungen

Zumindest wenn es welche gibt; mal sehen, wie oft ich dieses Tool benutzen werde. Die Dinge, die mich am meisten stören, werden wahrscheinlich zuerst behoben werden.

Ach ja, noch etwas: Danke für’s Lesen des gesamten Artikels. Zur Belohnung gibt es eine Kopie QR-codengrave kostenlos zum Herunterladen. Es kann für den privaten Gebrauch gern verwendet werden. Falls Sie eine kommerzielle Nutzung planen, lassen Sie es mich bitte im Voraus wissen.