diff --git a/.gitignore b/.gitignore index 1de1e72..63234fb 100644 --- a/.gitignore +++ b/.gitignore @@ -415,4 +415,5 @@ Codesys/Scripts/*.out.txt Codesys/Scripts/temp/ .DS_Store /Codesys/Scripts/st +Codesys/Scripts/archive/st/FB_LicenseStatus* OpenCV/FastTemplateMatching/_bench_out/ diff --git a/Codesys/ArfBot.project b/Codesys/ArfBot.project index fa51151..1a63b98 100644 Binary files a/Codesys/ArfBot.project and b/Codesys/ArfBot.project differ diff --git a/Codesys/ArfBot.xml b/Codesys/ArfBot.xml index 647c694..ff776f2 100644 --- a/Codesys/ArfBot.xml +++ b/Codesys/ArfBot.xml @@ -1,7 +1,7 @@  - - + + https://github.com/dalethomas81/ArfBotOS @@ -1733,7 +1733,7 @@ END_VAR - + git describe --tags --always --dirty (does not get the latest tag from main) @@ -1762,7 +1762,7 @@ END_VAR VAR_GLOBAL // git describe --tags --always --dirty (does not get the latest tag from main) // git describe --tags --always main (gets the latest tag from main but the commit is wrong because its from the branch we work from - dev) - sPlcVersion : STRING(80) := 'v2.2026.37.1-AlarmHistory-23-gb1a767d-dirty'; + sPlcVersion : STRING(80) := 'v2.2026.38.0-RoiEditor'; sRelease : STRING(80); sVersionLine : STRING(160); END_VAR @@ -1788,7 +1788,7 @@ END_VAR - + @@ -1831,7 +1831,7 @@ END_VAR TYPE ST_CncCmd : STRUCT StartPos: SMC_PosInfo; - OriConvention: SMC_ORI_CONVENTION:=SMC_ORI_CONVENTION.ZYX; + OriConvention: SMC_ORI_CONVENTION:=SMC_ORI_CONVENTION.ZYZ; Filename: STRING:='CNC_FromFile.cnc'; CoordinateType: DUT_Command_CoordinateType:=DUT_Command_CoordinateType.Variable; ArrayIndex: INT:=GVL.DEC_START_POS; @@ -4749,7 +4749,7 @@ IF NOT PersistentVars.Flags[GVL.INIT_POS_IDX].Value THEN PersistentVars.StoredPositions[GVL.POSE_3_IDX].Position.Y := 0.0; PersistentVars.StoredPositions[GVL.POSE_3_IDX].Position.Z := 0.0; PersistentVars.StoredPositions[GVL.POSE_3_IDX].Position.A := 0.0; - PersistentVars.StoredPositions[GVL.POSE_3_IDX].Position.B := 0.0; + PersistentVars.StoredPositions[GVL.POSE_3_IDX].Position.B := 90.0; PersistentVars.StoredPositions[GVL.POSE_3_IDX].Position.C := 0.0; PersistentVars.StoredPositions[GVL.POSE_4_IDX].Position.X := 0.0; @@ -7297,7 +7297,10 @@ IoConfig_Globals.J2.in.bLimitPos := rx_j2_limit; IoConfig_Globals.J3.in.bLimitPos := TRUE; IoConfig_Globals.J4.in.bLimitPos := TRUE; IoConfig_Globals.J5.in.bLimitPos := TRUE; -IoConfig_Globals.J6.in.bLimitPos := rx_j6_limit; +//IoConfig_Globals.J6.in.bLimitPos := rx_j6_limit; +// J6 home flag is not a travel stop — 360 deg tool-Z / cone orbits will cross it. +// Homing still uses rx_j6_limit as SMC_Homing.bReferenceSwitch (NOT J6_Limit). +IoConfig_Globals.J6.in.bLimitPos := TRUE; @@ -12891,7 +12894,6 @@ VAR _SMC_CheckVelocities: SMC_CheckVelocities; _SMC_GEOINFO: ARRAY[0..50] OF SMC_GEOINFO; _StartPos: SMC_PosInfo; - // ATTENTION: ZYZ is the only orientation convention that was reliable during testing. _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYZ; _ExecuteRtrig: R_TRIG; _Execute: BOOL; @@ -12961,7 +12963,6 @@ VAR _SMC_CheckVelocities: SMC_CheckVelocities; _SMC_GEOINFO: ARRAY[0..50] OF SMC_GEOINFO; _StartPos: SMC_PosInfo; - // ATTENTION: ZYZ is the only orientation convention that was reliable during testing. _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYZ; _ExecuteRtrig: R_TRIG; _Execute: BOOL; @@ -13024,9 +13025,6 @@ END_VAR - - ATTENTION: ZYZ is the only orientation convention that was reliable during testing. - @@ -13084,7 +13082,6 @@ VAR _SMC_CheckVelocities: SMC_CheckVelocities; _SMC_GEOINFO: ARRAY[0..50] OF SMC_GEOINFO; _StartPos: SMC_PosInfo; - // ATTENTION: ZYZ is the only orientation convention that was reliable during testing. _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYZ; _ExecuteRtrig: R_TRIG; _Execute: BOOL; @@ -13136,7 +13133,6 @@ VAR _SMC_CheckVelocities: SMC_CheckVelocities; _SMC_GEOINFO: ARRAY[0..50] OF SMC_GEOINFO; _StartPos: SMC_PosInfo; - // ATTENTION: ZYZ is the only orientation convention that was reliable during testing. _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYZ; _ExecuteRtrig: R_TRIG; _Execute: BOOL; @@ -13224,7 +13220,7 @@ _SMC_NCInterpreter( nSizeOutQueue:= SIZEOF(_SMC_GEOINFO), pbyBufferOutQueue:= ADR(_SMC_GEOINFO), bEnableSyntaxChecks:= , - eOriConv:= _OriConvention, // ATTENTION: ZYZ is the only orientation convention that was reliable during testing. + eOriConv:= _OriConvention, dCircleTolerance:= , pInterpreterStack:= , nInterpreterStackSizeBytes:= , @@ -13454,7 +13450,6 @@ VAR _SMC_CheckVelocities: SMC_CheckVelocities; _SMC_GEOINFO: ARRAY[0..50] OF SMC_GEOINFO; _StartPos: SMC_PosInfo; - // ATTENTION: ZYZ is the only orientation convention that was reliable during testing. _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYZ; _ExecuteRtrig: R_TRIG; _Execute: BOOL; @@ -14075,7 +14070,7 @@ CASE OriConvention OF SMC_ORI_CONVENTION.ZYX: // yaw(Z), pitch(Y) and roll(X) SMC_Matrix3_ToYawPitchRoll( mR:=_SMC_Trafof_ArticulatedRobot_6DOF.mToolOrientation, - dRoll_rad=> Position.dA, dPitch_rad=> Position.dB, dYaw_rad=> Position.dC); + dYaw_rad=> Position.dA, dPitch_rad=> Position.dB, dRoll_rad=> Position.dC); Position.dA := F_RadToDeg(Position.dA); Position.dB := F_RadToDeg(Position.dB); Position.dC := F_RadToDeg(Position.dC); @@ -17684,16 +17679,27 @@ _Vision(sIpAddress:= VisionSystemIpAddress, uPort:= VisionSystemPort); // _VisionBusyRtrig(clk:=_Vision.P_Busy); IF _VisionBusyRtrig.Q THEN - VisionStateText := 'BUSY'; + VisionStateText := 'Busy'; END_IF // _VisionDoneRtrig(clk:=_Vision.P_Done); IF _VisionDoneRtrig.Q THEN - VisionStateText := 'DONE'; - VisionStateText := CONCAT(VisionStateText, ' Found '); - VisionStateText := CONCAT(VisionStateText, UDINT_TO_STRING(_PartsCount)); - VisionStateText := CONCAT(VisionStateText, ' parts'); + VisionStateText := 'Done'; + + IF _ResultPosition_CAL > 0 THEN + VisionStateText := CONCAT(VisionStateText, ': Calibration Complete'); + //VisionStateText := CONCAT(VisionStateText, _Vision.P_Response); // TODO add the response output? + + ELSIF _ResultPosition_LOC > 0 THEN + VisionStateText := CONCAT(VisionStateText, ': Found '); + VisionStateText := CONCAT(VisionStateText, UDINT_TO_STRING(_PartsCount)); + VisionStateText := CONCAT(VisionStateText, ' parts'); + + ELSIF _ResultPosition_CAP > 0 THEN + VisionStateText := CONCAT(VisionStateText, ': Capture complete'); + + END_IF END_IF @@ -22411,7 +22417,7 @@ END_VAR VAR_OUTPUT END_VAR VAR - _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; + _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; // _SMC_Trafo_ArticulatedRobot_6DOF is always ZYX (yaw pitch roll) according to https://content.helpme-codesys.com/en/CODESYS%20SoftMotion/_sm_trafo_pou_six_dof_robot.html _SMC_TrafoConfig_ArticulatedRobot_6DOF: SMC_TrafoConfig_ArticulatedRobot_6DOF; _SMC_Trafo_ArticulatedRobot_6DOF: SMC_Trafo_ArticulatedRobot_6DOF; _SMC_ControlAxisByPos_J1: SMC_ControlAxisByPos; @@ -22525,7 +22531,7 @@ END_VAR VAR_OUTPUT END_VAR VAR - _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; + _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; // _SMC_Trafo_ArticulatedRobot_6DOF is always ZYX (yaw pitch roll) according to https://content.helpme-codesys.com/en/CODESYS%20SoftMotion/_sm_trafo_pou_six_dof_robot.html _SMC_TrafoConfig_ArticulatedRobot_6DOF: SMC_TrafoConfig_ArticulatedRobot_6DOF; _SMC_Trafo_ArticulatedRobot_6DOF: SMC_Trafo_ArticulatedRobot_6DOF; _SMC_ControlAxisByPos_J1: SMC_ControlAxisByPos; @@ -22587,6 +22593,9 @@ END_VAR + + _SMC_Trafo_ArticulatedRobot_6DOF is always ZYX (yaw pitch roll) according to https://content.helpme-codesys.com/en/CODESYS%20SoftMotion/_sm_trafo_pou_six_dof_robot.html + @@ -22711,7 +22720,7 @@ END_VAR VAR_OUTPUT END_VAR VAR - _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; + _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; // _SMC_Trafo_ArticulatedRobot_6DOF is always ZYX (yaw pitch roll) according to https://content.helpme-codesys.com/en/CODESYS%20SoftMotion/_sm_trafo_pou_six_dof_robot.html _SMC_TrafoConfig_ArticulatedRobot_6DOF: SMC_TrafoConfig_ArticulatedRobot_6DOF; _SMC_Trafo_ArticulatedRobot_6DOF: SMC_Trafo_ArticulatedRobot_6DOF; _SMC_ControlAxisByPos_J1: SMC_ControlAxisByPos; @@ -22905,7 +22914,7 @@ END_VAR VAR_OUTPUT END_VAR VAR - _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; + _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; // _SMC_Trafo_ArticulatedRobot_6DOF is always ZYX (yaw pitch roll) according to https://content.helpme-codesys.com/en/CODESYS%20SoftMotion/_sm_trafo_pou_six_dof_robot.html _SMC_TrafoConfig_ArticulatedRobot_6DOF: SMC_TrafoConfig_ArticulatedRobot_6DOF; _SMC_Trafo_ArticulatedRobot_6DOF: SMC_Trafo_ArticulatedRobot_6DOF; _SMC_ControlAxisByPos_J1: SMC_ControlAxisByPos; @@ -23039,7 +23048,7 @@ END_VAR VAR_OUTPUT END_VAR VAR - _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; + _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; // _SMC_Trafo_ArticulatedRobot_6DOF is always ZYX (yaw pitch roll) according to https://content.helpme-codesys.com/en/CODESYS%20SoftMotion/_sm_trafo_pou_six_dof_robot.html _SMC_TrafoConfig_ArticulatedRobot_6DOF: SMC_TrafoConfig_ArticulatedRobot_6DOF; _SMC_Trafo_ArticulatedRobot_6DOF: SMC_Trafo_ArticulatedRobot_6DOF; _SMC_ControlAxisByPos_J1: SMC_ControlAxisByPos; @@ -23142,7 +23151,6 @@ CASE SUPER^._SeqMain OF _STEP_COMMAND: // _CncCmdParser(Command:=Command); - // ATTENTION: ZYZ is the only orientation convention that was reliable during testing. _PlanPath.M_SetOriConvention(OriConvention:=_OriConvention);//_CncCmdParser.stCommand.OriConvention); _PlanPath.M_SetStartPosition(StartPos:=_TrackPosition.Position);//_CncCmdParser.stCommand.StartPos); _PlanPath.M_Execute(Filename:=_CncCmdParser.stCommand.Filename); // TODO I think we should be careful here. May need to sync CheckVelocities with the Interpolator @@ -23262,7 +23270,6 @@ CASE WORD_TO_INT(_SMC_Interpolator.wM) OF END_CASE -// ATTENTION: ZYZ is the only orientation convention that was reliable during testing. _TrackPosition( OriConvention:=_OriConvention, AxisGroup:=ArfBotAxisGroup, @@ -23347,7 +23354,7 @@ _SMC_ControlAxisByPos_J4( iStatus:= _SMC_Interpolator.iStatus, bEnable:= _SMC_Interpolator.bWorking, bAvoidGaps:= AvoidGaps, - fSetPosition:= -_SMC_Trafo_ArticulatedRobot_6DOF.dQ3, // this has to be negative for it to work right. the trafo function blocks just arent working right. (see notes above) + fSetPosition:= _SMC_Trafo_ArticulatedRobot_6DOF.dQ3, fGapVelocity:= GapVelocity, fGapAcceleration:= GapAccel, fGapDeceleration:= GapAccel, @@ -23379,7 +23386,7 @@ _SMC_ControlAxisByPos_J6( iStatus:= _SMC_Interpolator.iStatus, bEnable:= _SMC_Interpolator.bWorking, bAvoidGaps:= AvoidGaps, - fSetPosition:= -_SMC_Trafo_ArticulatedRobot_6DOF.dQ5, // this has to be negative for it to work right. the trafo function blocks just arent working right. (see notes above) + fSetPosition:= _SMC_Trafo_ArticulatedRobot_6DOF.dQ5, fGapVelocity:= GapVelocity, fGapAcceleration:= GapAccel, fGapDeceleration:= GapAccel, @@ -23706,7 +23713,7 @@ END_VAR VAR_OUTPUT END_VAR VAR - _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; + _OriConvention: SMC_ORI_CONVENTION := SMC_ORI_CONVENTION.ZYX; // _SMC_Trafo_ArticulatedRobot_6DOF is always ZYX (yaw pitch roll) according to https://content.helpme-codesys.com/en/CODESYS%20SoftMotion/_sm_trafo_pou_six_dof_robot.html _SMC_TrafoConfig_ArticulatedRobot_6DOF: SMC_TrafoConfig_ArticulatedRobot_6DOF; _SMC_Trafo_ArticulatedRobot_6DOF: SMC_Trafo_ArticulatedRobot_6DOF; _SMC_ControlAxisByPos_J1: SMC_ControlAxisByPos; @@ -46826,13 +46833,13 @@ END_TYPE - 165 + 720 fSWLimitPositive Software limit in positive direction - -180 + -720 fSWLimitNegative Software limit in negative direction diff --git a/Codesys/Scripts/CutPlcRelease.py b/Codesys/Scripts/CutPlcRelease.py new file mode 100644 index 0000000..03eaa19 --- /dev/null +++ b/Codesys/Scripts/CutPlcRelease.py @@ -0,0 +1,197 @@ +# Stamp GVL_Version.sPlcVersion with the next release tag, then export XML. +# +# Daily WIP still uses PreparePlcCommit.py (oldtag-N-gSHA[-dirty]). +# Run this when the current dev commit is the release: +# Tools -> Scripting -> Execute Script File -> CutPlcRelease.py +# A popup asks for the slug (or a full tag). The script does not git tag. +# +# Headless (no popup): pass the slug after -- +# python Codesys\Scripts\RunCodesysScript.py --script Codesys\Scripts\CutPlcRelease.py --project Codesys\ArfBot.project --no-ui --text-prompts -- RoiEditor +from __future__ import print_function +import os +import sys +import traceback + +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _SCRIPT_DIR) +import StampPlcVersion as stamp +import PlcReleaseTag as reltag + +stamp.bind_host(sys.modules.get("__main__")) +if not (len(sys.argv) > 2 and sys.argv[2]): + stamp.LOG_FILE = os.path.join(_SCRIPT_DIR, "CutPlcRelease.out.txt") + + +def extra_script_args(): + if stamp.launched_headless(): + start = 3 + else: + start = 1 + result = [] + for raw in sys.argv[start:]: + value = stamp.clean_arg(raw) + if value and value != "--": + result.append(value) + return result + + +def parse_flags(args): + slug = None + dry_run = False + skip_confirm = False + for arg in args: + lower = arg.lower() + if lower in ("--dry-run", "dry-run", "-n"): + dry_run = True + continue + if lower in ("--yes", "-y", "yes"): + skip_confirm = True + continue + if slug is None: + slug = arg + return slug, dry_run, skip_confirm + + +def ui_present(): + try: + return bool(system.ui_present) + except Exception: + return False + + +def list_tags(git_root): + listing, _code = stamp.run_git(["tag", "--list"], git_root) + return [line.strip() for line in listing.splitlines() if line.strip()] + + +def export_plcopen_xml(proj): + filename = str(os.path.splitext(proj.path)[0] + ".xml") + stamp.emit("Exporting PLCopen XML: {0}".format(filename)) + objects = proj.get_children() + proj.export_xml( + objects, + path=filename, + recursive=True, + export_folder_structure=True, + declarations_as_plaintext=True, + ) + stamp.emit("XML export done") + + +def confirm_stamp(tag): + try: + choice = PromptChoice.YesNo + default = PromptResult.Yes + yes = PromptResult.Yes + except NameError: + stamp.emit("Prompt enums missing; skipping confirm dialog") + return True + result = system.ui.prompt( + "Write sPlcVersion := '{0}' and export ArfBot.xml?\n" + "This does not create the git tag. Commit first, then tag that commit.".format(tag), + choice, + default, + ) + return result == yes + + +def ask_tag(prefix): + message = ( + "Release tag for GVL_Version.sPlcVersion.\n" + "Prefix {0} is today's ISO week (next unused n).\n" + "Append a feature slug after the hyphen, or edit the full tag. Cancel aborts.".format(prefix) + ) + entered = system.ui.query_string( + message, + text=prefix, + multi_line=False, + cancellable=True, + ) + if entered is None: + raise RuntimeError("CutPlcRelease cancelled") + return entered + + +def follow_up_commands(tag): + stamp.emit("") + stamp.emit("Next (after reviewing the diff):") + stamp.emit(" git add Codesys/ArfBot.project Codesys/ArfBot.xml") + stamp.emit(" git commit -m \"Stamp PLC version {0}\"".format(tag)) + stamp.emit(" python Codesys\\Scripts\\TagPlcRelease.py") + stamp.emit("Then open the PR into main and create the GitHub Release from that existing tag.") + + +def main(): + stamp.emit("CutPlcRelease starting") + proj = stamp.ensure_project() + stamp.emit("Project: {0}".format(proj.path)) + + git_root = stamp.find_git_root(proj.path) + stamp.emit("Git root: {0}".format(git_root)) + + user_slug, dry_run, skip_confirm = parse_flags(extra_script_args()) + latest, ref = stamp.latest_tag_on_main(git_root) + all_tags = list_tags(git_root) + prefix = reltag.propose_prefix(all_tags, latest) + stamp.emit("Proposed prefix: {0}".format(prefix)) + + if user_slug: + entered = user_slug + stamp.emit("Slug/tag from argv: {0}".format(entered)) + elif ui_present(): + entered = ask_tag(prefix) + stamp.emit("Slug/tag from dialog: {0}".format(entered)) + else: + raise RuntimeError( + "No slug given. Pass one after -- (headless) or run from " + "Tools -> Scripting -> Execute Script File for the popup." + ) + + tag = reltag.resolve_user_tag(entered, prefix) + reltag.validate_release_tag(tag, all_tags, max_len=stamp.MAX_VERSION_LEN) + stamp.emit("sPlcVersion := '{0}'".format(tag)) + + if dry_run: + stamp.emit("Dry run: project not saved, XML not exported") + follow_up_commands(tag) + stamp.emit("script finished.") + return + + if ui_present() and not skip_confirm: + if not confirm_stamp(tag): + raise RuntimeError("CutPlcRelease cancelled at confirm") + + gvl = stamp.find_textual(proj, "GVL_Version") + if gvl is None or not gvl.has_textual_declaration: + raise RuntimeError("GVL_Version not found or has no textual declaration") + + old = gvl.textual_declaration.text + new = stamp.stamp_declaration(old, tag) + if new == old: + stamp.emit("GVL_Version.sPlcVersion already set") + else: + gvl.textual_declaration.replace(new_text=new) + stamp.emit("Updated GVL_Version.sPlcVersion") + + proj.save() + stamp.emit("Project saved") + export_plcopen_xml(proj) + follow_up_commands(tag) + stamp.emit("script finished.") + + +try: + main() +except Exception as exc: + stamp.emit("ERROR: {0}".format(exc)) + stamp.emit(traceback.format_exc()) + try: + if ui_present(): + system.ui.error("CutPlcRelease: {0}".format(exc)) + except Exception: + pass + stamp.write_log() + stamp.maybe_exit(1) +else: + stamp.write_log() + stamp.maybe_exit(0) diff --git a/Codesys/Scripts/PlcReleaseTag.py b/Codesys/Scripts/PlcReleaseTag.py new file mode 100644 index 0000000..ef47311 --- /dev/null +++ b/Codesys/Scripts/PlcReleaseTag.py @@ -0,0 +1,193 @@ +# CalVer helpers for ArfBotOS release tags. +# +# Tag shape: v{major}.{year}.{iso-week}.{n}-{Slug} +# Example: v2.2026.38.0-RoiEditor +# +# Stdlib only so CPython can unit-test and preview a tag without CODESYS: +# python Codesys\Scripts\PlcReleaseTag.py RoiEditor +from __future__ import print_function +import datetime +import re +import sys + + +TAG_RE = re.compile( + r"^v(\d+)\.(\d{4})\.(\d{1,2})\.(\d+)(?:-([A-Za-z][A-Za-z0-9]*))?$" +) +SLUG_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") +MAX_VERSION_LEN = 80 + + +def parse_tag(tag): + if tag is None: + return None + text = tag.strip() + match = TAG_RE.match(text) + if not match: + return None + return { + "major": int(match.group(1)), + "year": int(match.group(2)), + "week": int(match.group(3)), + "n": int(match.group(4)), + "slug": match.group(5) or "", + "text": text, + } + + +def iso_year_week(today=None): + if today is None: + today = datetime.date.today() + iso = today.isocalendar() + return int(iso[0]), int(iso[1]) + + +def next_n(parsed_tags, major, year, week): + numbers = [ + item["n"] + for item in parsed_tags + if item["major"] == major and item["year"] == year and item["week"] == week + ] + if not numbers: + return 0 + return max(numbers) + 1 + + +def format_tag(major, year, week, n, slug): + return "v{0}.{1}.{2}.{3}-{4}".format(major, year, week, n, slug) + + +def propose_prefix(all_tags, latest_main_tag, today=None): + year, week = iso_year_week(today) + parsed_main = parse_tag(latest_main_tag) if latest_main_tag else None + major = parsed_main["major"] if parsed_main else 2 + parsed_all = [] + for tag in all_tags or []: + parsed = parse_tag(tag) + if parsed is not None: + parsed_all.append(parsed) + n = next_n(parsed_all, major, year, week) + return "v{0}.{1}.{2}.{3}-".format(major, year, week, n) + + +def resolve_user_tag(user_text, proposed_prefix): + text = (user_text or "").strip() + if not text: + raise ValueError("A release slug or full tag is required") + parsed = parse_tag(text) + if parsed is not None: + if not parsed["slug"]: + raise ValueError("Tag '{0}' is missing a feature slug".format(text)) + return format_tag( + parsed["major"], + parsed["year"], + parsed["week"], + parsed["n"], + parsed["slug"], + ) + if SLUG_RE.match(text): + return proposed_prefix + text + raise ValueError( + "Expected a feature slug or a full tag like {0}MyFeature, got '{1}'".format( + proposed_prefix, text + ) + ) + + +def extract_plc_version(xml_text): + if not xml_text: + return "" + match = re.search( + r'.*? max_len: + raise ValueError( + "Tag '{0}' is {1} chars; STRING({2}) max".format(tag, len(tag), max_len) + ) + if tag in existing_tags: + raise ValueError("Git tag '{0}' already exists".format(tag)) + return parsed + + +def _decode(output): + if output is None: + return "" + if not isinstance(output, str): + return output.decode("utf-8") + return output + + +def _git(args, cwd): + import subprocess + + output = subprocess.check_output(["git"] + args, cwd=cwd) + return _decode(output).strip() + + +def preview_from_git(git_root, slug=None, today=None): + latest = None + for ref in ("main", "origin/main", "master", "origin/master"): + try: + latest = _git(["describe", "--tags", "--abbrev=0", ref], git_root) + if latest: + break + except Exception: + latest = None + listing = _git(["tag", "--list"], git_root) + all_tags = [line.strip() for line in listing.splitlines() if line.strip()] + prefix = propose_prefix(all_tags, latest, today=today) + if slug: + tag = resolve_user_tag(slug, prefix) + validate_release_tag(tag, all_tags) + return latest, prefix, tag, all_tags + return latest, prefix, None, all_tags + + +def main(argv=None): + if argv is None: + argv = sys.argv[1:] + slug = argv[0] if argv else None + import os + + git_root = find_git_root(sys.path[0] if sys.path else os.getcwd()) + latest, prefix, tag, _all_tags = preview_from_git(git_root, slug=slug) + print("Latest tag on main: {0}".format(latest or "")) + print("Proposed prefix: {0}".format(prefix)) + if tag: + print("Release tag: {0}".format(tag)) + else: + print("Pass a slug to preview the full tag, e.g. RoiEditor") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Codesys/Scripts/README.md b/Codesys/Scripts/README.md index 1006e3f..bed77ab 100644 --- a/Codesys/Scripts/README.md +++ b/Codesys/Scripts/README.md @@ -22,7 +22,7 @@ This folder is ignored by git and should stay local-only. Example: ```powershell -python Codesys\Scripts\RunCodesysScript.py --script Codesys\Scripts\ListDeviceTree.py --project Codesys\ArfBot.project --no-ui --text-prompts +python Codesys\Scripts\RunCodesysScript.py --script Codesys\Scripts\PreparePlcCommit.py --project Codesys\ArfBot.project --no-ui --text-prompts ``` ## Prepare a PLC commit (stamp + export) @@ -35,18 +35,47 @@ That sets `GVL_Version.sPlcVersion` to `--g[-dirty]`, sav The tag is `git describe --tags --abbrev=0 main` (falls back to `origin/main`). The hash and commit count are from the branch you have checked out. `-dirty` is included when tracked files differ from `HEAD` (computed before the GVL write). -Stamp-only: `StampPlcVersion.py`. Export-only: `PLCOpenExport.py`. +Stamp-only: `StampPlcVersion.py`. Prepare and Cut already export XML. + +## Cut a PLC release (clean tag, no -N-gSHA) + +When this `dev` commit **is** the release, do not use `PreparePlcCommit.py` (that stamps `oldtag-N-gSHA[-dirty]`). Run `CutPlcRelease.py` instead: + +**Tools → Scripting → Execute Script File…** → `Codesys/Scripts/CutPlcRelease.py` + +A popup is prefilled with the next CalVer prefix (`v2.{year}.{iso-week}.{n}-`, from today's week and existing tags). Type a slug (`RoiEditor`) or edit the full tag, then confirm. That writes `sPlcVersion` to the clean tag, saves, and exports XML. It does **not** create the git tag. Commit first, then tag **that** commit from the XML stamp: + +```bat +git add Codesys/ArfBot.project Codesys/ArfBot.xml +git commit -m "Stamp PLC version v2.2026.38.0-RoiEditor" +python Codesys\Scripts\TagPlcRelease.py +``` + +Then PR into `main` and create the GitHub Release from the existing tag (do not retag the merge commit). + +Headless (no popup): + +```powershell +python Codesys\Scripts\RunCodesysScript.py --script Codesys\Scripts\CutPlcRelease.py --project Codesys\ArfBot.project --output Codesys\Scripts\CutPlcRelease.out.txt --no-ui --text-prompts -- RoiEditor +``` + +Preview the next prefix without CODESYS: + +```powershell +python Codesys\Scripts\PlcReleaseTag.py RoiEditor +``` ## Included Scripts - `RunCodesysScript.py`: Python wrapper that detects the local CODESYS install and launches a script through the CODESYS command line. -- `ListDeviceTree.py`: Opens the target project if needed and writes the CODESYS device tree to `Codesys/Scripts/ListDeviceTree.out.txt`. -- `PatchTuningDeadTime.py`: Writes the deadtime-suggestion logic into `_00_Main` / `_M_Tuning` and relabels the Tuning DeadTime field. ST sources live in `Codesys/Scripts/st/`. -- `parse_retain.py`: Decodes `BackupRetain.ret` / `Application.ret` program data and regenerates `st/M_BuildTests_impl.st`. -- `PatchBuildTests.py`: Writes `st/M_BuildTests_impl.st` into `_00_Main.M_BuildTests` and saves `ArfBot.project`. Does not re-export PLCopen XML (that export changes format). -- `PatchLicenseStatus.py`: Creates/updates `FB_LicenseStatus`, wires `GVL.LicenseStatus` and `_00_Main`, adds Component Manager + CmpEventMgr, then builds. - `PreparePlcCommit.py`: Daily helper — stamps `sPlcVersion` then exports `ArfBot.xml`. Run from **Tools → Scripting → Execute Script File**. +- `CutPlcRelease.py`: Release helper — popup (or argv slug) for the next CalVer tag, stamps `sPlcVersion` to that clean tag, exports XML. Does not `git tag`. +- `TagPlcRelease.py`: After the stamp commit, create an annotated git tag from `sPlcVersion` in `Codesys/ArfBot.xml` and push it to origin. +- `PlcReleaseTag.py`: CalVer helpers (`v{major}.{year}.{iso-week}.{n}-{Slug}`). Runnable in CPython to preview the next tag. - `StampPlcVersion.py`: Stamp-only (`GVL_Version.sPlcVersion` from git). -- `PLCOpenExport.py`: Export-only PLCopen XML next to the open `.project`. +- `parse_retain.py`: Decodes `BackupRetain.ret` / `Application.ret` program data and regenerates `st/M_BuildTests_impl.st`. +- `PatchBuildTests.py`: Writes `st/M_BuildTests_impl.st` into `_00_Main.M_BuildTests` and saves `ArfBot.project`. Does not re-export PLCopen XML (that export changes format). + +One-shot probes and already-applied patches live in `archive/`. ## Temp scripts One-shot probes, dumps, and experiments go in `Codesys/Scripts/temp/` (gitignored). Do not commit them. diff --git a/Codesys/Scripts/RunCodesysScript.py b/Codesys/Scripts/RunCodesysScript.py index dbfd2bb..7368fbd 100644 --- a/Codesys/Scripts/RunCodesysScript.py +++ b/Codesys/Scripts/RunCodesysScript.py @@ -16,7 +16,7 @@ def default_project_path(): def default_output_path(): scripts_dir = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(scripts_dir, "ListDeviceTree.out.txt") + return os.path.join(scripts_dir, "PreparePlcCommit.out.txt") def detect_codesys_exe(): @@ -140,7 +140,7 @@ def main(): ) parser.add_argument( "--script", - default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "ListDeviceTree.py"), + default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "PreparePlcCommit.py"), help="Path to the CODESYS Python script to run", ) parser.add_argument( diff --git a/Codesys/Scripts/TagPlcRelease.py b/Codesys/Scripts/TagPlcRelease.py new file mode 100644 index 0000000..57e92a5 --- /dev/null +++ b/Codesys/Scripts/TagPlcRelease.py @@ -0,0 +1,244 @@ +# Create an annotated git tag from GVL_Version.sPlcVersion in Codesys/ArfBot.xml. +# +# Run AFTER committing the CutPlcRelease stamp (the XML in HEAD is the source of truth): +# Tools -> Scripting -> Execute Script File -> TagPlcRelease.py +# python Codesys\Scripts\TagPlcRelease.py +# python Codesys\Scripts\TagPlcRelease.py --dry-run +# python Codesys\Scripts\TagPlcRelease.py --no-push +# +# IronPython (CODESYS) must not use subprocess - that raises +# DeprecationWarning: sys.exc_clear() not supported in 3.x as Script Messages errors. +from __future__ import print_function +import argparse +import os +import sys + +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _SCRIPT_DIR) +import PlcReleaseTag as reltag + + +XML_REL = "Codesys/ArfBot.xml" +PROJECT_REL = "Codesys/ArfBot.project" +STAGED_PATHS = (XML_REL, PROJECT_REL) + + +def running_in_codesys(): + host = sys.modules.get("__main__") + return host is not None and hasattr(host, "projects") + + +def quote_arg(arg): + arg = str(arg) + if not arg or any(ch in arg for ch in ' \t"'): + return '"' + arg.replace('"', '\\"') + '"' + return arg + + +def git_via_process(args, cwd, check=True): + from System.Diagnostics import Process, ProcessStartInfo + + psi = ProcessStartInfo() + psi.FileName = "git.exe" + psi.Arguments = " ".join([quote_arg(a) for a in args]) + psi.WorkingDirectory = cwd + psi.RedirectStandardOutput = True + psi.RedirectStandardError = True + psi.UseShellExecute = False + psi.CreateNoWindow = True + + proc = Process() + proc.StartInfo = psi + if not proc.Start(): + raise RuntimeError("Could not start git.exe") + out = proc.StandardOutput.ReadToEnd().strip() + err = proc.StandardError.ReadToEnd().strip() + proc.WaitForExit() + code = proc.ExitCode + proc.Close() + if check and code != 0: + raise RuntimeError( + "git {0} failed (exit {1}): {2}".format( + " ".join(args), code, err or out or "no output" + ) + ) + return out, code + + +def git_via_subprocess(args, cwd, check=True): + import subprocess + + process = subprocess.Popen( + ["git"] + list(args), + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out, err = process.communicate() + out = reltag._decode(out).strip() + err = reltag._decode(err).strip() + if check and process.returncode != 0: + raise RuntimeError( + "git {0} failed (exit {1}): {2}".format( + " ".join(args), process.returncode, err or out or "no output" + ) + ) + return out, process.returncode + + +def git(args, cwd, check=True): + try: + return git_via_process(args, cwd, check) + except ImportError: + return git_via_subprocess(args, cwd, check) + + +def porcelain_for(git_root, paths): + out, _code = git(["status", "--porcelain", "--"] + list(paths), git_root) + return out + + +def list_tags(git_root): + out, _code = git(["tag", "--list"], git_root) + return [line.strip() for line in out.splitlines() if line.strip()] + + +def head_sha(git_root): + out, _code = git(["rev-parse", "HEAD"], git_root) + return out + + +def short_sha(git_root): + out, _code = git(["rev-parse", "--short", "HEAD"], git_root) + return out + + +def xml_at_head(git_root): + out, code = git(["show", "HEAD:{0}".format(XML_REL)], git_root, check=False) + if code != 0: + raise RuntimeError( + "HEAD has no {0}. Commit the CutPlcRelease stamp first.".format(XML_REL) + ) + return out + + +def tag_commit(git_root, tag): + out, code = git( + ["rev-parse", "--verify", "--quiet", "{0}^{{commit}}".format(tag)], + git_root, + check=False, + ) + if code != 0 or not out: + return None + return out + + +def require_committed_stamp(git_root): + dirty = porcelain_for(git_root, STAGED_PATHS) + if dirty: + raise RuntimeError( + "Commit the stamped PLC files first. Uncommitted:\n{0}".format(dirty) + ) + + +def tag_from_head_xml(git_root): + text = xml_at_head(git_root) + version = reltag.extract_plc_version(text) + if not version: + raise RuntimeError("GVL_Version.sPlcVersion not found in HEAD {0}".format(XML_REL)) + reltag.validate_release_tag(version, [], max_len=reltag.MAX_VERSION_LEN) + existing = list_tags(git_root) + pointed = tag_commit(git_root, version) + if pointed is None: + reltag.validate_release_tag(version, existing, max_len=reltag.MAX_VERSION_LEN) + return version, pointed + + +def create_tag(git_root, tag): + git(["tag", "-a", tag, "-m", tag], git_root) + + +def push_tag(git_root, tag): + git(["push", "origin", "refs/tags/{0}".format(tag)], git_root) + + +def cli_argv(argv): + if argv is None: + argv = sys.argv[1:] + filtered = [] + for raw in argv: + value = raw.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + lower = value.lower() + if not value or value == "--": + continue + if lower.endswith(".project") or lower.endswith(".txt"): + continue + filtered.append(value) + return filtered + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Tag HEAD with sPlcVersion from the committed Codesys/ArfBot.xml." + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the tag and checks; do not create or push it", + ) + parser.add_argument( + "--no-push", + action="store_true", + help="Create the local tag only; do not push to origin", + ) + args = parser.parse_args(cli_argv(argv)) + do_push = not args.dry_run and not args.no_push + + git_root = reltag.find_git_root(_SCRIPT_DIR) + require_committed_stamp(git_root) + tag, pointed = tag_from_head_xml(git_root) + head = head_sha(git_root) + print("HEAD: {0} ({1})".format(short_sha(git_root), head[:12])) + print("Stamp: {0}".format(tag)) + + if pointed is not None and pointed != head: + raise RuntimeError( + "Tag {0} already exists on {1}, not HEAD ({2})".format( + tag, pointed[:12], head[:12] + ) + ) + + if args.dry_run: + if pointed == head: + print("Tag {0} already points at HEAD.".format(tag)) + else: + print("Dry run: would create annotated tag {0} on HEAD".format(tag)) + print("Dry run: would push origin {0}".format(tag)) + return 0 + + if pointed == head: + print("Tag {0} already points at HEAD.".format(tag)) + else: + create_tag(git_root, tag) + print("Created annotated tag {0}".format(tag)) + + if do_push: + print("Pushing {0} to origin...".format(tag)) + push_tag(git_root, tag) + print("Pushed origin {0}".format(tag)) + else: + print("Local tag only. Push later with: git push origin {0}".format(tag)) + return 0 + + +if __name__ == "__main__": + code = 0 + try: + code = main() + except Exception as exc: + print("ERROR: {0}".format(exc)) + code = 1 + if not running_in_codesys(): + sys.exit(code) diff --git a/Codesys/Scripts/InstallRobotAnimator.py b/Codesys/Scripts/archive/InstallRobotAnimator.py similarity index 100% rename from Codesys/Scripts/InstallRobotAnimator.py rename to Codesys/Scripts/archive/InstallRobotAnimator.py diff --git a/Codesys/Scripts/ListDeviceTree.py b/Codesys/Scripts/archive/ListDeviceTree.py similarity index 100% rename from Codesys/Scripts/ListDeviceTree.py rename to Codesys/Scripts/archive/ListDeviceTree.py diff --git a/Codesys/Scripts/NativeExport.py b/Codesys/Scripts/archive/NativeExport.py similarity index 100% rename from Codesys/Scripts/NativeExport.py rename to Codesys/Scripts/archive/NativeExport.py diff --git a/Codesys/Scripts/PLCOpenExport.py b/Codesys/Scripts/archive/PLCOpenExport.py similarity index 100% rename from Codesys/Scripts/PLCOpenExport.py rename to Codesys/Scripts/archive/PLCOpenExport.py diff --git a/Codesys/Scripts/PatchLicenseStatus.py b/Codesys/Scripts/archive/PatchLicenseStatus.py similarity index 100% rename from Codesys/Scripts/PatchLicenseStatus.py rename to Codesys/Scripts/archive/PatchLicenseStatus.py diff --git a/Codesys/Scripts/PatchTuningDeadTime.py b/Codesys/Scripts/archive/PatchTuningDeadTime.py similarity index 100% rename from Codesys/Scripts/PatchTuningDeadTime.py rename to Codesys/Scripts/archive/PatchTuningDeadTime.py diff --git a/Codesys/Scripts/archive/README.md b/Codesys/Scripts/archive/README.md new file mode 100644 index 0000000..d04c804 --- /dev/null +++ b/Codesys/Scripts/archive/README.md @@ -0,0 +1,5 @@ +# Archived CODESYS scripts + +One-shot probes, already-applied project patches, and `PLCOpenExport.py` (Prepare/Cut already export XML). Kept for history; not part of the daily or release workflow. + +To run one, point `RunCodesysScript.py --script` at the file in this folder. Patch scripts that load ST look for `archive/st/` next to themselves. diff --git a/Codesys/Scripts/st/_00_Main_decl.st b/Codesys/Scripts/archive/st/_00_Main_decl.st similarity index 100% rename from Codesys/Scripts/st/_00_Main_decl.st rename to Codesys/Scripts/archive/st/_00_Main_decl.st diff --git a/Codesys/Scripts/st/_M_Tuning_decl.st b/Codesys/Scripts/archive/st/_M_Tuning_decl.st similarity index 100% rename from Codesys/Scripts/st/_M_Tuning_decl.st rename to Codesys/Scripts/archive/st/_M_Tuning_decl.st diff --git a/Codesys/Scripts/st/_M_Tuning_impl.st b/Codesys/Scripts/archive/st/_M_Tuning_impl.st similarity index 100% rename from Codesys/Scripts/st/_M_Tuning_impl.st rename to Codesys/Scripts/archive/st/_M_Tuning_impl.st diff --git a/Codesys/_cnc/CNC_Cone.cnc b/Codesys/_cnc/CNC_Cone.cnc new file mode 100644 index 0000000..95be012 --- /dev/null +++ b/Codesys/_cnc/CNC_Cone.cnc @@ -0,0 +1,26 @@ +( G91 incremental | G17 xy | G02 cw with XY end and R ) +( Trafo ABC = yaw / pitch / roll. Look-at the apex at every corner. ) +( Jog TCP onto the apex, tool Z along the cone axis, then run. ) +( H = 40, R = 23.094, cone half-angle = 30 ) +( ) +( End of each quarter, ABC that point tool Z at the apex: ) +( start / N060: A0 B30 C0 lean in +X, pitch ) +( N070 +Y: A0 B0 C30 lean in -Y, roll ) +( N080 +X: A0 B-30 C0 lean in -X, pitch the other way ) +( N090 -Y: A0 B0 C-30 lean in +Y, roll the other way ) +( N100 -X: A0 B30 C0 back to start ) +( G91 values below are the deltas that reach those end angles. ) + +N000 G91 +N020 G17 + +N050 G01 X0 Y0 Z0 A0 B30 C0 F30 +N060 G01 X-23.094 Y0 Z-40 A0 B0 C0 F30 + +N070 G02 X23.094 Y23.094 R23.094 A0 B-30 C30 F30 +N080 G02 X23.094 Y-23.094 R23.094 A0 B-30 C-30 F30 +N090 G02 X-23.094 Y-23.094 R23.094 A0 B30 C-30 F30 +N100 G02 X-23.094 Y23.094 R23.094 A0 B30 C30 F30 + +N110 G01 X23.094 Y0 Z40 A0 B-30 C0 F30 +N130 G53 diff --git a/OpenCV/CameraCalibration/CalibrateCamera.py b/OpenCV/CameraCalibration/CalibrateCamera.py index 0a990a5..6c5a0f7 100644 --- a/OpenCV/CameraCalibration/CalibrateCamera.py +++ b/OpenCV/CameraCalibration/CalibrateCamera.py @@ -22,7 +22,9 @@ def capture_image(debug, width, height): config = camera.create_preview_configuration(main={"size": (width, height), "format": "RGB888"}) camera.configure(config) camera.start() - time.sleep(0.1) + # First frames are often dark while auto-exposure settles. + time.sleep(0.4) + camera.capture_array() image = camera.capture_array() camera.stop() camera.close() @@ -255,6 +257,158 @@ def rotate_coordinates_clockwise(CoordinatesToRotate, CoordinatesToRotateAround, # Return the new coordinates as a list of floats return [float(new_x), float(new_y)] +def _log(msg): + sys.stderr.write(str(msg) + "\n") + sys.stderr.flush() + +def _as_corner_array(corners): + corners = numpy.asarray(corners, dtype=numpy.float32) + if corners.ndim == 2: + corners = corners.reshape(-1, 1, 2) + elif corners.ndim == 3 and corners.shape[1] != 1: + corners = corners.reshape(-1, 1, 2) + return corners + +def _gray_views(gray): + views = [("gray", gray)] + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + views.append(("clahe", clahe.apply(gray))) + views.append(("invert", cv2.bitwise_not(gray))) + return views + +def _sb_flags(exhaustive): + flags = 0 + if hasattr(cv2, "CALIB_CB_NORMALIZE_IMAGE"): + flags |= cv2.CALIB_CB_NORMALIZE_IMAGE + if hasattr(cv2, "CALIB_CB_ACCURACY"): + flags |= cv2.CALIB_CB_ACCURACY + if exhaustive and hasattr(cv2, "CALIB_CB_EXHAUSTIVE"): + flags |= cv2.CALIB_CB_EXHAUSTIVE + return flags + +def _try_sb(image, pattern_size, exhaustive): + if not hasattr(cv2, "findChessboardCornersSB"): + return False, None + flags = _sb_flags(exhaustive) + try: + ret, corners = cv2.findChessboardCornersSB(image, pattern_size, flags=flags) + except TypeError: + ret, corners = cv2.findChessboardCornersSB(image, pattern_size, flags) + if ret: + return True, _as_corner_array(corners) + return False, None + +def _try_classic(image, pattern_size): + # FAST_CHECK is omitted: it false-negatives dense and square boards. + flags = cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE + ret, corners = cv2.findChessboardCorners(image, pattern_size, flags=flags) + if ret: + return True, _as_corner_array(corners) + return False, None + +def _find_at_scale(gray, pattern_size, use_exhaustive): + views = _gray_views(gray) + + for name, img in views: + found, corners = _try_sb(img, pattern_size, False) + if found: + _log("checkerboard found with findChessboardCornersSB (%s)" % name) + return True, corners, hasattr(cv2, "CALIB_CB_ACCURACY") + + for name, img in views: + found, corners = _try_classic(img, pattern_size) + if found: + _log("checkerboard found with findChessboardCorners (%s)" % name) + return True, corners, False + + if use_exhaustive: + for name, img in views[:2]: + found, corners = _try_sb(img, pattern_size, True) + if found: + _log("checkerboard found with findChessboardCornersSB exhaustive (%s)" % name) + return True, corners, hasattr(cv2, "CALIB_CB_ACCURACY") + + return False, None, False + +def estimate_inner_corners(gray): + """Rough inner-corner count from checker period. Overlay hint only.""" + h, w = gray.shape[:2] + k = 15 + gray_f = gray.astype(numpy.float32) + mu = cv2.blur(gray_f, (k, k)) + var = cv2.blur(gray_f * gray_f, (k, k)) - mu * mu + var_n = cv2.normalize(var, None, 0, 255, cv2.NORM_MINMAX).astype(numpy.uint8) + ys, xs = numpy.where(var_n > 40) + if xs.size < 100: + return None + crop = gray[int(ys.min()):int(ys.max()) + 1, int(xs.min()):int(xs.max()) + 1] + if crop.size == 0 or min(crop.shape[:2]) < 20: + return None + row = cv2.GaussianBlur(crop[crop.shape[0] // 2:crop.shape[0] // 2 + 1, :].astype(numpy.float32), (1, 5), 0).ravel() + row = row - row.mean() + corr = numpy.correlate(row, row, mode="full") + corr = corr[corr.size // 2:] + period = None + for i in range(4, min(80, corr.size - 1)): + if corr[i] > corr[i - 1] and corr[i] > corr[i + 1] and corr[i] > 0.2 * corr[0]: + period = i + break + if period is None: + return None + square_px = period / 2.0 + if square_px < 3: + return None + inner_x = max(1, int(round(crop.shape[1] / square_px)) - 1) + inner_y = max(1, int(round(crop.shape[0] / square_px)) - 1) + return square_px, inner_x, inner_y, (w, h) + +def find_checkerboard(gray, pattern_size): + """Locate inner chessboard corners. + + Square high-count boards often fail findChessboardCorners with + CALIB_CB_FAST_CHECK. Try the sector-based detector first, then classic + detection without FAST_CHECK. On small captures, retry at 2x/3x because + 5 mm squares at 640x400 are only a few pixels. + """ + inner = pattern_size[0] * pattern_size[1] + square = pattern_size[0] == pattern_size[1] + use_exhaustive = square or inner >= 80 + patterns = [pattern_size] + if pattern_size[0] != pattern_size[1]: + patterns.append((pattern_size[1], pattern_size[0])) + + scales = [1.0] + if min(gray.shape[:2]) < 1000: + scales.extend([2.0, 3.0]) + + for scale in scales: + if scale == 1.0: + img = gray + else: + img = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC) + _log("retrying checkerboard detect at %.1fx (%sx%s)" % (scale, img.shape[1], img.shape[0])) + for pattern in patterns: + found, corners, sub = _find_at_scale(img, pattern, use_exhaustive and scale == 1.0) + if found: + if scale != 1.0: + corners = corners / scale + if pattern != pattern_size: + _log("found swapped inner corners %sx%s" % (pattern[0], pattern[1])) + return True, corners, sub, pattern + + _log("checkerboard not found for inner corners %sx%s" % (pattern_size[0], pattern_size[1])) + return False, None, False, pattern_size + +def subpix_window(corners, pattern_size): + avg = calculate_average_square_size(True, corners, pattern_size) + if avg is None or avg <= 0: + return (5, 5) + size = int(round(avg / 3.0)) + if size % 2 == 0: + size -= 1 + size = max(3, min(11, size)) + return (size, size) + def drawOrientation(image, origin, rotation): # y-green x-red z-blue start_point = (int(origin[0]), int(origin[1])) @@ -290,38 +444,35 @@ def main(checkerboard, squaresize, resultfile, debug, width, height): # Vector for 2D points twodpoints = [] - # 3D points real world coordinates - objectp3d = numpy.zeros((1, checkerboard[0] - * checkerboard[1], - 3), numpy.float32) - objectp3d[0, :, :2] = numpy.mgrid[0:checkerboard[0], - 0:checkerboard[1]].T.reshape(-1, 2) - objectp3d = objectp3d * squaresize prev_img_shape = None image = capture_image(debug, width, height) grayColor = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - # Find the chess board corners - # If desired number of corners are - # found in the image then ret = true - ret, corners = cv2.findChessboardCorners( - grayColor, checkerboard, - cv2.CALIB_CB_ADAPTIVE_THRESH - + cv2.CALIB_CB_FAST_CHECK + - cv2.CALIB_CB_NORMALIZE_IMAGE) + ret, corners, already_subpixel, found_pattern = find_checkerboard(grayColor, checkerboard) # If desired number of corners can be detected then, # refine the pixel coordinates and display # them on the images of checker board if ret == True: + checkerboard = found_pattern + objectp3d = numpy.zeros((1, checkerboard[0] + * checkerboard[1], + 3), numpy.float32) + objectp3d[0, :, :2] = numpy.mgrid[0:checkerboard[0], + 0:checkerboard[1]].T.reshape(-1, 2) + objectp3d = objectp3d * squaresize threedpoints.append(objectp3d) - # Refining pixel coordinates - # for given 2d points. - corners2 = cv2.cornerSubPix( - grayColor, corners, (11, 11), (-1, -1), criteria) + # SB with CALIB_CB_ACCURACY is already sub-pixel. A fixed 11x11 + # cornerSubPix window is too large for 5 mm squares. + if already_subpixel: + corners2 = corners + else: + win = subpix_window(corners, checkerboard) + corners2 = cv2.cornerSubPix( + grayColor, corners, win, (-1, -1), criteria) # get pixel average so we can calulate how many pixels per user units #pixel_average = get_pixel_average(corners2, checkerboard) @@ -368,6 +519,19 @@ def main(checkerboard, squaresize, resultfile, debug, width, height): pixel_ratio = -1 rotation_offset = -1 reprojection_error = -1 + fail = image.copy() + h, w = fail.shape[:2] + scale = max(0.5, min(w, h) / 640.0) + font = cv2.FONT_HERSHEY_SIMPLEX + label = "checkerboard not found %sx%s" % (checkerboard[0], checkerboard[1]) + cv2.putText(fail, label, (16, int(32 * scale) + 8), font, 0.7 * scale, (0, 0, 255), max(1, int(2 * scale))) + hint = "%sx%s capture" % (w, h) + estimate = estimate_inner_corners(grayColor) + if estimate is not None: + square_px, inner_x, inner_y, _ = estimate + hint = "%sx%s ~%.0fpx/sq try %sx%s inner" % (w, h, square_px, inner_x, inner_y) + cv2.putText(fail, hint, (16, int(64 * scale) + 8), font, 0.55 * scale, (0, 0, 255), max(1, int(2 * scale))) + cv2.imwrite(resultfile, fail) return pixel_ratio, rotation_offset, reprojection_error diff --git a/OpenCV/CameraCalibration/README.md b/OpenCV/CameraCalibration/README.md index 30292cc..cb15c48 100644 --- a/OpenCV/CameraCalibration/README.md +++ b/OpenCV/CameraCalibration/README.md @@ -11,8 +11,12 @@ At a high level the script runs this pipeline: - In debug mode it loads `Input.jpg`. - In normal mode it captures a frame using `Picamera2`. 3. Convert the image to grayscale. -4. Detect checkerboard corners with `cv2.findChessboardCorners()`. -5. Refine the detected corners with `cv2.cornerSubPix()`. +4. Detect checkerboard corners with `find_checkerboard()`: + - `cv2.findChessboardCornersSB()` first (better on dense / square boards such as 17x17) + - lighting-normalized views (CLAHE and inverted) + - classic `cv2.findChessboardCorners()` without `CALIB_CB_FAST_CHECK` + - exhaustive SB search last for square or high-count boards +5. Refine classic detections with `cv2.cornerSubPix()` using a window scaled to square size. SB detections with `CALIB_CB_ACCURACY` are already sub-pixel. 6. Build the 3D checkerboard reference points using the supplied checkerboard size and physical square size. 7. Run `cv2.calibrateCamera()` to compute the intrinsic matrix, distortion coefficients, and reprojection error. 8. Derive additional vision alignment values from the detected checkerboard: @@ -26,7 +30,7 @@ At a high level the script runs this pipeline: 11. Undistort the result image using the newly computed calibration. 12. Save the annotated image and print a `CAL` summary line. -If the checkerboard cannot be found, the script returns `-1` values for the computed outputs and does not generate a useful calibration result. +If the checkerboard cannot be found, the script returns `-1` values, writes the raw capture to the result image with a "not found" overlay, and logs the detector attempts to stderr (`vision_log.txt` on the PLC path). ## Inputs @@ -110,6 +114,8 @@ The script supports these arguments: - `-w` or `--width`: capture width, default `3280` - `-h` or `--height`: capture height, default `2464` +On the IMX477, size picks a sensor mode (FOV changes). 640×400 is a wide 2028×1080 crop; 640×640 is a zoomed 1332×990 center crop. Use the same size as Locate. See `Web/README.md`. + Example: ```bash @@ -144,7 +150,7 @@ That makes it useful for tuning checkerboard detection on a desktop machine befo ## Relationship To Other Files - `cal.yaml` is the important output of this script. -- `roi.yaml` exists in this folder, but this script does not currently write or use it during the active code path. +- `roi.yaml` exists in this folder, but this script does not currently write or use it during the active code path. Locate's live `roi.yaml` is next to the vision scripts and is edited from the Vision web utility. - The `patterns` folder is present for calibration-related assets, but the current script directly reads a captured image rather than iterating a set of pattern files. ## Current Notes @@ -152,4 +158,5 @@ That makes it useful for tuning checkerboard detection on a desktop machine befo - This script calibrates from a single detected checkerboard image, not from a batch of multiple calibration views. - `filter_image()` contains an optional crop path, but the active calibration flow uses undistortion without cropping. - There are helper functions for rotating and cropping images that are not used in the current main path. -- On failure to find the checkerboard, the script reports `-1` values rather than throwing an exception. +- On failure to find the checkerboard, the script reports `-1` values, writes the raw capture with a "not found" overlay, and logs detector attempts to stderr. +- `X`/`Y` are inner corners, not squares. An 18x18 square plate is `17 17`. Square high-count boards use `findChessboardCornersSB` because the classic FAST_CHECK path often misses them. diff --git a/OpenCV/FastTemplateMatching/FastTemplateMatching.py b/OpenCV/FastTemplateMatching/FastTemplateMatching.py index 77a00fc..f82edf5 100644 --- a/OpenCV/FastTemplateMatching/FastTemplateMatching.py +++ b/OpenCV/FastTemplateMatching/FastTemplateMatching.py @@ -312,13 +312,27 @@ def _xy(pt): arr = numpy.asarray(pt, dtype=numpy.float64).reshape(-1) return float(arr[0]), float(arr[1]) -def crop_image(image, top_left, bot_right): +def clamp_roi_to_image(image, top_left, bot_right): + ih, iw = image.shape[:2] x0, y0 = _xy(top_left) x1, y1 = _xy(bot_right) - x, y = int(x0), int(y0) - w, h = int(x1 - x0), int(y1 - y0) - cropped = image[y:y+h, x:x+w].copy() - return cropped + orig = ( + int(round(min(x0, x1))), + int(round(min(y0, y1))), + int(round(max(x0, x1))), + int(round(max(y0, y1))), + ) + left = max(0, min(orig[0], iw)) + right = max(0, min(orig[2], iw)) + top = max(0, min(orig[1], ih)) + bottom = max(0, min(orig[3], ih)) + return orig, (left, top, right, bottom) + +def crop_image(image, top_left, bot_right): + _, (left, top, right, bottom) = clamp_roi_to_image(image, top_left, bot_right) + if right <= left or bottom <= top: + return image[0:0, 0:0].copy() + return image[top:bottom, left:right].copy() def str_to_bool (val): """Convert a string representation of truth to true (1) or false (0). @@ -894,7 +908,28 @@ def main(m_matSrc, m_matDst, savelocation, iMaxPos, dMaxOverlap, dScore, dTolera m_TemplData = learn_pattern(m_matDst) # make a copy of just the region of interest - m_matRoi = crop_image(m_matSrc, roi_top_left, roi_bot_right) + orig_roi, used_roi = clamp_roi_to_image(m_matSrc, roi_top_left, roi_bot_right) + left, top, right, bottom = used_roi + if right - left < 8 or bottom - top < 8: + ih, iw = m_matSrc.shape[:2] + sys.stderr.write( + "ROI %s,%s – %s,%s is outside %sx%s capture\n" + % (orig_roi[0], orig_roi[1], orig_roi[2], orig_roi[3], iw, ih) + ) + fail = m_matSrc if len(m_matSrc.shape) == 3 else cv2.cvtColor(m_matSrc, cv2.COLOR_GRAY2BGR) + label = "ROI outside %sx%s capture" % (iw, ih) + scale = max(0.6, min(iw, ih) / 640.0) + cv2.putText(fail, label, (16, int(36 * scale) + 8), cv2.FONT_HERSHEY_SIMPLEX, + 0.7 * scale, (0, 0, 255), max(1, int(2 * scale))) + cv2.imwrite(savelocation, fail) + return False + if used_roi != orig_roi: + sys.stderr.write( + "ROI clipped from %s,%s – %s,%s to %s,%s – %s,%s\n" + % (orig_roi[0], orig_roi[1], orig_roi[2], orig_roi[3], left, top, right, bottom) + ) + m_matRoi = m_matSrc[top:bottom, left:right].copy() + roi_top_left = (float(left), float(top)) #cv2.imshow("m_matRoi", m_matRoi) #cv2.waitKey(0) diff --git a/OpenCV/FastTemplateMatching/README.md b/OpenCV/FastTemplateMatching/README.md index cf35308..ce04e51 100644 --- a/OpenCV/FastTemplateMatching/README.md +++ b/OpenCV/FastTemplateMatching/README.md @@ -47,12 +47,14 @@ This folder already contains sample inputs including: The calibration data comes from [`CalibrateCamera.py`](c:/Users/dalet/Github/ArfBotOS/OpenCV/CameraCalibration/CalibrateCamera.py) in `OpenCV/CameraCalibration/`. Running that script produces a `cal.yaml` file with the camera matrix, distortion coefficients, pixel ratio, rotation offset, and reference points used here for undistortion and coordinate conversion. +Capture width×height must match calibration and ROI. On the IMX477, libcamera picks a different sensor mode per size (640×400 → 2028×1080 wide FOV; 640×640 → 1332×990 center crop / zoomed in; 2000×2000 → 4056×2160 then a square crop). See `Web/README.md`. + `roi.yaml` provides the active search window: - `top_left` - `bot_right` -The search happens only inside the ROI from `roi.yaml`, not across the whole captured image. That keeps the search faster and limits false positives. +The search happens only inside the ROI from `roi.yaml`, not across the whole captured image. That keeps the search faster and limits false positives. The Vision web utility (`/vision`, ROI tab) writes this file. Coordinates are in capture pixels and must fit the Locate width/height. If the window sits outside the capture, locate writes a "ROI outside" overlay and finds nothing; a window that only partly overlaps is clipped to the frame. In this folder, the checked-in `cal.yaml` is an example calibration output. If the camera, lens, mounting angle, or working distance changes, `CalibrateCamera.py` should be run again and the resulting `cal.yaml` should replace the old one for accurate results. diff --git a/Robots/AR4/Arduino/RemoteIO/PTO.cpp b/Robots/AR4/Arduino/RemoteIO/PTO.cpp index bb08d68..a35bf43 100644 --- a/Robots/AR4/Arduino/RemoteIO/PTO.cpp +++ b/Robots/AR4/Arduino/RemoteIO/PTO.cpp @@ -1,66 +1,124 @@ -// PTO.cpp #include "PTO.h" -PTO::PTO(int In_DirPin, int In_PulsePin, int In_EnablePin) { - dirPin = In_DirPin; - pulsePin = In_PulsePin; - enablePin = In_EnablePin; +PTO::PTO(int dirPin, int pulsePin, int enablePin){ + + _dirPin = dirPin; + _pulsePin = pulsePin; + _enablePin = enablePin; + _direction = true; // matches init() default DIR = HIGH + _sysTimeLast = 0; + _halfPeriodCycles = 0; + _frequencyLast = 0; + _offOneshot = false; + _pulseHigh = false; + enabled = false; } -void PTO::run(int16_t Frequency){ - if (Frequency != 0 && DriveEnabled) { +void PTO::init() { - bOffOneshot = true; + pinModeFast(_dirPin, OUTPUT); + pinModeFast(_pulsePin, OUTPUT); + if (_enablePin >= 0) { + pinModeFast(_enablePin, OUTPUT); + } + + digitalWriteFast(_dirPin, HIGH); + digitalWriteFast(_pulsePin, HIGH); + _direction = true; + _pulseHigh = true; + if (_enablePin >= 0) { + digitalWriteFast(_enablePin, LOW); + } - float Period, PulseTime; - Period = 1 / float(abs(Frequency)); // seconds - PulseTime = (Period * 1000 * 1000) / 2; // microseconds divide by 2 for 50% duty cycle + _sysTimeLast = ARM_DWT_CYCCNT; + _frequencyLast = 0; + _halfPeriodCycles = 0; +} - _SysTime = ARM_DWT_CYCCNT; - if (_SysTime - _SysTime_Last > (F_CPU_ACTUAL / 1000000 * PulseTime)) { - _SysTime_Last = _SysTime; - digitalToggleFast(pulsePin); - } +// Recompute timing and direction when the commanded frequency changes. +void PTO::applyFrequency(int16_t frequency) { - Direction = (Frequency < 0 ? HIGH : LOW); - digitalWriteFast(dirPin, Direction); + bool newDirHigh = (frequency < 0); - } else { - if (bOffOneshot){ - bOffOneshot = false; - digitalWriteFast(dirPin, false); - digitalWriteFast(pulsePin, false); + if (newDirHigh != _direction) { + // Most stepper drivers need DIR stable before the next active edge. + // Force the step line idle (LOW) before flipping DIR. + if (_pulseHigh) { + digitalWriteFast(_pulsePin, LOW); + _pulseHigh = false; } + _direction = newDirHigh; + digitalWriteFast(_dirPin, _direction ? HIGH : LOW); + // Re-base phase after a forced edge so we do not double-step immediately. + _sysTimeLast = ARM_DWT_CYCCNT; + } else if (_frequencyLast == 0) { + // Starting from stop: ensure DIR is driven and phase is fresh. + digitalWriteFast(_dirPin, _direction ? HIGH : LOW); + _sysTimeLast = ARM_DWT_CYCCNT; } -} -void PTO::init() { - pinModeFast(dirPin, OUTPUT); - pinModeFast(pulsePin, OUTPUT); - if (enablePin >= 0) { - pinModeFast(enablePin, OUTPUT); + // half-period in CPU cycles: F_cpu / (2 * |f|) + // Integer path avoids float rounding jitter on the compare threshold. + uint32_t absFreq = (uint32_t)abs(frequency); + uint32_t half = (uint32_t)((uint64_t)F_CPU_ACTUAL / (2ULL * (uint64_t)absFreq)); + if (half == 0) { + half = 1; // clamp to maximum achievable software rate } - - digitalWriteFast(dirPin, HIGH); - digitalWriteFast(pulsePin, HIGH); - if (enablePin >= 0) { - digitalWriteFast(enablePin, LOW); + _halfPeriodCycles = half; + _frequencyLast = frequency; +} + +void PTO::run(int16_t frequency) { + + if (frequency != 0 && enabled) { + + _offOneshot = true; + + if (frequency != _frequencyLast) { + applyFrequency(frequency); + } + + // Phase-locked edge generation: advance the deadline by a fixed period + // instead of snapping to "now". This preserves pulse phase across variable + // main-loop latency (SPI, encoder IRQs, etc.). + uint8_t edges = 0; + uint32_t now = ARM_DWT_CYCCNT; + while ((int32_t)(now - _sysTimeLast) >= (int32_t)_halfPeriodCycles + && edges < PTO_MAX_EDGES_PER_CALL) { + _sysTimeLast += _halfPeriodCycles; + digitalToggleFast(_pulsePin); + _pulseHigh = !_pulseHigh; + edges++; + now = ARM_DWT_CYCCNT; + } + + } else { + if (_offOneshot){ + _offOneshot = false; + _frequencyLast = 0; + _halfPeriodCycles = 0; + _pulseHigh = false; + digitalWriteFast(_dirPin, false); + digitalWriteFast(_pulsePin, false); + } } } -int PTO::turnON() { - DriveEnabled = true; - if (enablePin >= 0) { - digitalWriteFast(enablePin, HIGH); +int PTO::enable() { + + enabled = true; + if (_enablePin >= 0) { + digitalWriteFast(_enablePin, HIGH); } - return DriveEnabled; + return enabled; } -int PTO::turnOFF() { - DriveEnabled = false; - if (enablePin >= 0) { - digitalWriteFast(enablePin, LOW); +int PTO::disable() { + + enabled = false; + if (_enablePin >= 0) { + digitalWriteFast(_enablePin, LOW); } - return DriveEnabled; -} \ No newline at end of file + return enabled; +} diff --git a/Robots/AR4/Arduino/RemoteIO/PTO.h b/Robots/AR4/Arduino/RemoteIO/PTO.h index 8a3b81f..c725890 100644 --- a/Robots/AR4/Arduino/RemoteIO/PTO.h +++ b/Robots/AR4/Arduino/RemoteIO/PTO.h @@ -5,24 +5,35 @@ #include #include -#define THROW_ERROR_IF_NOT_FAST +#define THROW_ERROR_IF_NOT_FAST + +// Max half-period edges to emit per run() call. Limits blocking if the +// main loop falls far behind a high commanded frequency. +#ifndef PTO_MAX_EDGES_PER_CALL +#define PTO_MAX_EDGES_PER_CALL 8 +#endif class PTO { private: - bool Direction, Direction_Last; - uint32_t _SysTime, _SysTime_Last; - int dirPin; - int pulsePin; - int enablePin; - bool bOffOneshot; + bool _direction; // true = HIGH (negative frequency) + uint32_t _sysTimeLast; // cycle count at last edge (phase reference) + uint32_t _halfPeriodCycles; // CPU cycles per half-period (50% duty) + int16_t _frequencyLast; // last applied frequency (0 = stopped) + int _dirPin; + int _pulsePin; + int _enablePin; + bool _offOneshot; + bool _pulseHigh; // tracks step pin state for DIR setup + + void applyFrequency(int16_t frequency); public: - PTO(int In_DirPin, int In_PulsePin, int In_EnablePin = -1); + PTO(int dirPin, int pulsePin, int enablePin = -1); void init(); - void run(int16_t Frequency); - int turnON(); - int turnOFF(); - bool DriveEnabled; -}; + void run(int16_t frequency); + int enable(); + int disable(); + bool enabled; -#endif \ No newline at end of file +}; +#endif diff --git a/Web/README.md b/Web/README.md index 92ae83c..a6235e2 100644 --- a/Web/README.md +++ b/Web/README.md @@ -4,7 +4,7 @@ One Flask app on port 5000 that hosts the operator pages: | URL | Page | | --- | --- | -| `http://:5000/vision` | Vision template capture | +| `http://:5000/vision` | Vision template capture and locate ROI | | `http://:5000/vision/files` | Saved templates | | `http://:5000/animator` | 6-axis robot animator (joints, TCP / MCS / PCS, Euler convention) | | `http://:5000/bluetooth` | Bluetooth pairing (DualSense and other adapters) | @@ -12,7 +12,21 @@ One Flask app on port 5000 that hosts the operator pages: `/` redirects to `/vision` when vision is enabled, `/bluetooth` on a `--plc-only` install, or `/animator` if that is the only enabled page. Old `/template`, `/files/`, and `/output_sized` URLs redirect to the new paths. -The CODESYS HMI loads `/vision/output_sized`. Template files still go to `/var/opt/codesys/PlcLogic/Application/Vision/Templates`. The latest processed image is still `/var/opt/codesys/PlcLogic/visu/outputimage.jpg`. +The CODESYS HMI loads `/vision/output_sized`. Template files still go to `/var/opt/codesys/PlcLogic/Application/Vision/Templates`. Locate search ROI is `.../Vision/roi.yaml`, set from the Vision page ROI tab (`GET`/`POST /vision/roi`). The latest processed image is still `/var/opt/codesys/PlcLogic/visu/outputimage.jpg`. + +## Capture size and HQ camera FOV + +The IMX477 does not scale one full-sensor image to every width×height. libcamera picks a **sensor mode**, then the ISP center-crops that mode to the requested aspect ratio. Square 640×640 looking “zoomed in” vs 640×400 is expected. + +| Requested size | Sensor mode used | What you see | +| --- | --- | --- | +| **640×400** | **2028×1080** | Full sensor **width**, 16:9 (top/bottom of the 4:3 chip already cut). Widest of the small sizes. | +| **640×640** | **1332×990** | Center crop (~⅔ of the chip). Loses the sides and some top/bottom vs 640×400. | +| **640×480** | **1332×990** | Same cropped mode as 640×640. | +| **1920×1080** | **2028×1080** | Same wide 16:9 mode as 640×400. | +| **2000×2000** / **2016×2000** | **4056×2160** | Full-width 16:9, then a square-ish crop of that. | + +Native full 4:3 is 4056×3040 (or 2028×1520 binned). Use **one** size for calibration, ROI, templates, and Locate. Mixing 640×400 with 640×640 or 2000×2000 changes FOV, `pixelratio`, and where `roi.yaml` sits. Theme tokens match **ArfBot Night** in `Codesys/VisualizationStyles/ArfBot Night/`. @@ -45,6 +59,7 @@ Web/ | `ARFBOT_ENABLE_ANIMATOR` | `1` | Register `/animator` (always on in the Pi installer) | | `ARFBOT_TEMPLATE_DIR` | `/var/opt/codesys/PlcLogic/Application/Vision/Templates` | Saved crops | | `ARFBOT_VISU_OUTPUT` | `/var/opt/codesys/PlcLogic/visu/outputimage.jpg` | HMI image | +| `ARFBOT_ROI_FILE` | `/var/opt/codesys/PlcLogic/Application/Vision/roi.yaml` | Locate search window | The animator page loads the same `ElementWrapper.js` as the CODESYS HTML5 control (`Codesys/Html5Controls/RobotAnimator/`). From a git checkout it is served from that path. The installer also copies it to `Web/app/static/js/ElementWrapper.js` on the Pi. Sliders drive joints and SoftMotion poses locally; they are not live axis-group values (those stay on the WebVisu control). diff --git a/Web/app/__init__.py b/Web/app/__init__.py index a7d647c..375ca7e 100644 --- a/Web/app/__init__.py +++ b/Web/app/__init__.py @@ -1,3 +1,5 @@ +import os + from config import Config, env_flag @@ -6,6 +8,8 @@ def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) + if os.environ.get("ARFBOT_ROI_FILE"): + app.config["ROI_FILE"] = os.environ["ARFBOT_ROI_FILE"] enable_vision = env_flag("ARFBOT_ENABLE_VISION", True) enable_bluetooth = env_flag("ARFBOT_ENABLE_BLUETOOTH", True) diff --git a/Web/app/static/css/arfbot-night.css b/Web/app/static/css/arfbot-night.css index 7dfa9e1..a2f023d 100644 --- a/Web/app/static/css/arfbot-night.css +++ b/Web/app/static/css/arfbot-night.css @@ -130,6 +130,11 @@ p { } .page.centered { text-align: center; } +.page.vision-page { + max-width: 100%; + width: 100%; + box-sizing: border-box; +} .flash-list { list-style: none; @@ -235,6 +240,10 @@ p { align-items: center; justify-content: center; } +.controls[hidden], +#croppedcontainer[hidden] { + display: none !important; +} button, .btn { @@ -453,18 +462,94 @@ pre.log { /* Vision capture */ .vision-title { margin: 8px 0 16px; } +.mode-toggle { + display: inline-flex; + border: 1px solid var(--frame); + border-radius: var(--radius); + overflow: hidden; + margin: 0 auto 12px; +} +.mode-toggle button { + border: none; + border-radius: 0; + margin: 0; + background: var(--control); + color: var(--muted); +} +.mode-toggle button.active { + background: var(--accent); + color: var(--text-on-accent); +} +#roi-status { + color: var(--accent); + font-weight: 650; + margin-top: 4px; +} +#roi-status.warn { + color: var(--danger); +} +.capture-notes { + max-width: 720px; + margin: 8px auto 0; + text-align: left; + background: var(--surface); + border: 1px solid var(--frame-soft); + border-radius: var(--radius); + padding: 8px 12px 10px; +} +.capture-notes summary { + cursor: pointer; + color: var(--muted); + font-weight: 650; + font-size: 12px; + letter-spacing: 0.02em; +} +.capture-notes summary:hover { color: var(--accent); } +.capture-notes p { + margin: 8px 0; + font-size: 12px; +} +.capture-notes table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.capture-notes th, +.capture-notes td { + border-bottom: 1px solid var(--frame-soft); + padding: 6px 8px; + vertical-align: top; +} +.capture-notes th { + color: var(--muted); + font-weight: 650; + text-align: left; +} +.capture-notes td { color: var(--text); } #results { color: var(--muted); margin-bottom: 12px; } -.img { position: absolute; z-index: 1; } +.img { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + object-fit: contain; + object-position: top left; + z-index: 1; +} #imgcontainer { display: inline-block; width: 640px; height: 400px; + max-width: 100%; margin: 16px auto; background: var(--canvas); position: relative; + overflow: hidden; + text-align: left; border: 2px solid var(--frame); border-radius: var(--radius); box-shadow: var(--shadow); @@ -479,13 +564,22 @@ pre.log { box-shadow: var(--shadow); min-width: 40px; min-height: 8px; + max-width: 100%; +} +#croppedcontainer img { + display: block; + max-width: min(100%, 640px); + height: auto; } #canvas { cursor: crosshair; border: 1px solid var(--frame-soft); - position: relative; + position: absolute; + left: 0; + top: 0; margin: 0 auto; z-index: 20; + user-select: none; } .file-grid { diff --git a/Web/app/templates/base.html b/Web/app/templates/base.html index af565e6..e8c3e8e 100644 --- a/Web/app/templates/base.html +++ b/Web/app/templates/base.html @@ -8,7 +8,7 @@ {% else %} ArfBot {% endif %} - + {% block head %}{% endblock %} diff --git a/Web/app/templates/vision/template.html b/Web/app/templates/vision/template.html index 7c2e2b5..6fee222 100644 --- a/Web/app/templates/vision/template.html +++ b/Web/app/templates/vision/template.html @@ -1,11 +1,17 @@ {% extends "base.html" %} -{% block page_class %}centered{% endblock %} +{% block page_class %}centered vision-page{% endblock %} {% block content %} -

Click and Drag to Create a Template

+

Click and Drag to Create a Template

+ +
+ + +

pointer location: x: , y:

+

ROI: loading…

@@ -18,84 +24,248 @@

Click and Drag to Create a Template

+
+ Capture size vs field of view (IMX477) +

libcamera picks a sensor mode from width×height, then center-crops to that aspect. Different sizes do not see the same scene. Use the same size for cal, ROI, templates, and Locate.

+ + + + + + + + + + + +
RequestedSensor modeField of view
640×4002028×1080Full sensor width, 16:9. Widest of the small sizes.
640×6401332×990Center crop (~⅔ of the chip). Zoomed in; sides and some top/bottom are lost vs 640×400.
640×4801332×990Same cropped mode as 640×640.
1920×10802028×1080Same wide 16:9 mode as 640×400.
2000×20004056×2160Full-width 16:9, then a square crop of that.
+
+
-
+
- +
+ +
{% endblock %} diff --git a/Web/app/vision.py b/Web/app/vision.py index 1fb0f13..74561d5 100644 --- a/Web/app/vision.py +++ b/Web/app/vision.py @@ -1,5 +1,6 @@ import io import os +import re import cv2 from flask import ( @@ -8,6 +9,7 @@ abort, current_app, flash, + jsonify, redirect, render_template, request, @@ -39,6 +41,92 @@ def visu_output_path(): return current_app.config["VISU_OUTPUT"] +DEFAULT_ROI_TL = [0.0, 0.0] +DEFAULT_ROI_BR = [640.0, 400.0] +_ROI_DATA_RE = re.compile( + r"data:\s*\[\s*([-+0-9.eE]+)\s*,\s*([-+0-9.eE]+)", +) + + +def roi_file_path(): + configured = current_app.config.get("ROI_FILE") + if configured: + return configured + return os.path.join(os.path.dirname(upload_folder()), "roi.yaml") + + +def _parse_roi_yaml(text): + def vec_after(key): + idx = text.find(key) + if idx < 0: + raise ValueError("missing %s" % key) + match = _ROI_DATA_RE.search(text[idx:idx + 400]) + if not match: + raise ValueError("no data for %s" % key) + return [float(match.group(1)), float(match.group(2))] + + return vec_after("top_left"), vec_after("bot_right") + + +def read_roi(path=None): + path = path or roi_file_path() + if not os.path.isfile(path): + return list(DEFAULT_ROI_TL), list(DEFAULT_ROI_BR) + with open(path, "r") as handle: + return _parse_roi_yaml(handle.read()) + + +def write_roi(top_left, bot_right, path=None): + path = path or roi_file_path() + folder = os.path.dirname(path) + if folder and not os.path.exists(folder): + os.makedirs(folder) + body = ( + "%YAML:1.0\n" + "---\n" + "top_left: !!opencv-matrix\n" + " rows: 2\n" + " cols: 1\n" + " dt: f\n" + " data: [ {0:.2f}, {1:.2f} ]\n" + "bot_right: !!opencv-matrix\n" + " rows: 2\n" + " cols: 1\n" + " dt: f\n" + " data: [ {2:.2f}, {3:.2f} ]\n" + ).format(top_left[0], top_left[1], bot_right[0], bot_right[1]) + tmp = path + ".tmp" + with open(tmp, "w") as handle: + handle.write(body) + os.replace(tmp, path) + + +def _as_xy(value, name): + if not isinstance(value, (list, tuple)) or len(value) != 2: + raise ValueError("%s must be [x, y]" % name) + return float(value[0]), float(value[1]) + + +def normalize_roi(top_left, bot_right, max_w=None, max_h=None): + x0, y0 = _as_xy(top_left, "top_left") + x1, y1 = _as_xy(bot_right, "bot_right") + left, right = sorted((x0, x1)) + top, bottom = sorted((y0, y1)) + if max_w is None or max_h is None: + global capArray + if capArray is not None: + max_h, max_w = capArray.shape[:2] + else: + max_w, max_h = 5000, 5000 + left = max(0.0, min(left, max_w - 1)) + top = max(0.0, min(top, max_h - 1)) + right = max(left + 1.0, min(right, float(max_w))) + bottom = max(top + 1.0, min(bottom, float(max_h))) + if right - left < 8 or bottom - top < 8: + raise ValueError("ROI is too small") + return [left, top], [right, bottom] + + @bp.route("/upload", methods=["GET", "POST"]) def upload_file(): if request.method == "POST": @@ -164,6 +252,33 @@ def template_legacy(): return redirect(url_for("vision.template")) +@bp.route("/vision/roi", methods=["GET", "POST"]) +def roi(): + if request.method == "GET": + top_left, bot_right = read_roi() + return jsonify({ + "top_left": top_left, + "bot_right": bot_right, + "path": roi_file_path(), + }) + + payload = request.get_json(silent=True) or {} + try: + top_left, bot_right = normalize_roi( + payload.get("top_left"), + payload.get("bot_right"), + ) + write_roi(top_left, bot_right) + except (TypeError, ValueError) as exc: + return jsonify({"status": "error", "error": str(exc)}), 400 + return jsonify({ + "status": "saved", + "top_left": top_left, + "bot_right": bot_right, + "path": roi_file_path(), + }) + + @bp.route("/captured_image") def captured_image(): width = request.args.get("width", default=640, type=int) diff --git a/Web/config.py b/Web/config.py index 21261af..2d250a7 100644 --- a/Web/config.py +++ b/Web/config.py @@ -22,3 +22,7 @@ class Config(object): "ARFBOT_VISU_OUTPUT", "/var/opt/codesys/PlcLogic/visu/outputimage.jpg", ) + ROI_FILE = os.environ.get( + "ARFBOT_ROI_FILE", + "/var/opt/codesys/PlcLogic/Application/Vision/roi.yaml", + ) diff --git a/Web/tests/test_app_factory.py b/Web/tests/test_app_factory.py index 8714861..4753dca 100644 --- a/Web/tests/test_app_factory.py +++ b/Web/tests/test_app_factory.py @@ -84,6 +84,7 @@ def test_full_app_keeps_hmi_output_sized(self): self.assertIn("/", rules) self.assertIn("/vision", rules) self.assertIn("/vision/files", rules) + self.assertIn("/vision/roi", rules) self.assertIn("/vision/output_sized", rules) self.assertIn("/output_sized", rules) self.assertIn("/captured_image", rules) diff --git a/Web/tests/test_roi.py b/Web/tests/test_roi.py new file mode 100644 index 0000000..c151613 --- /dev/null +++ b/Web/tests/test_roi.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def _stub_cv2(): + cv2 = types.ModuleType("cv2") + cv2.imread = lambda *args, **kwargs: None + cv2.imwrite = lambda *args, **kwargs: None + cv2.imencode = lambda *args, **kwargs: (True, b"") + cv2.resize = lambda *args, **kwargs: None + sys.modules.setdefault("cv2", cv2) + + +class RoiApiTests(unittest.TestCase): + def setUp(self): + try: + import flask # noqa: F401 + except ImportError: + self.skipTest("flask not installed") + _stub_cv2() + self.tmp = tempfile.TemporaryDirectory() + self.roi_path = os.path.join(self.tmp.name, "roi.yaml") + with patch.dict(os.environ, { + "ARFBOT_ENABLE_VISION": "1", + "ARFBOT_ENABLE_BLUETOOTH": "0", + "ARFBOT_ENABLE_ANIMATOR": "0", + "ARFBOT_ROI_FILE": self.roi_path, + }): + from app import create_app + + self.app = create_app() + self.app.config["ROI_FILE"] = self.roi_path + self.client = self.app.test_client() + + def tearDown(self): + self.tmp.cleanup() + + def test_get_default_when_missing(self): + response = self.client.get("/vision/roi") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["top_left"], [0.0, 0.0]) + self.assertEqual(data["bot_right"], [640.0, 400.0]) + + def test_post_writes_opencv_yaml(self): + response = self.client.post("/vision/roi", json={ + "top_left": [40, 20], + "bot_right": [400, 300], + }) + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["status"], "saved") + self.assertEqual(data["top_left"], [40.0, 20.0]) + self.assertEqual(data["bot_right"], [400.0, 300.0]) + self.assertTrue(os.path.isfile(self.roi_path)) + text = Path(self.roi_path).read_text() + self.assertIn("top_left: !!opencv-matrix", text) + self.assertIn("data: [ 40.00, 20.00 ]", text) + self.assertIn("data: [ 400.00, 300.00 ]", text) + + again = self.client.get("/vision/roi") + loaded = again.get_json() + self.assertEqual(loaded["top_left"], [40.0, 20.0]) + self.assertEqual(loaded["bot_right"], [400.0, 300.0]) + + def test_post_rejects_tiny_roi(self): + response = self.client.post("/vision/roi", json={ + "top_left": [10, 10], + "bot_right": [12, 12], + }) + self.assertEqual(response.status_code, 400) + + def test_post_normalizes_reversed_corners(self): + response = self.client.post("/vision/roi", json={ + "top_left": [400, 300], + "bot_right": [40, 20], + }) + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["top_left"], [40.0, 20.0]) + self.assertEqual(data["bot_right"], [400.0, 300.0]) + + def test_vision_page_has_roi_mode(self): + page = self.client.get("/vision") + self.assertEqual(page.status_code, 200) + html = page.get_data(as_text=True) + self.assertIn('id="mode-roi"', html) + self.assertIn('id="save-roi"', html) + self.assertIn("/vision/roi", html) + self.assertIn("Capture size vs field of view", html) + self.assertIn("1332", html) + + +if __name__ == "__main__": + unittest.main()